Skip to main content

faucet_core/file_format/
excel.rs

1//! Excel (`.xlsx`) read/write for the file connectors (#604).
2//!
3//! **Excel is not streamable.** A workbook is a zip container whose central
4//! directory sits at the end, and `calamine` parses a whole worksheet into a
5//! range before the first cell is readable. So both directions hold the whole
6//! object in memory, and callers must size `batch_size` accordingly — the one
7//! format in this module where peak memory is the file rather than the page.
8//! [`FileFormat::requires_whole_object`](super::FileFormat::requires_whole_object)
9//! reports this so a connector can pick its read strategy without a match on
10//! the format.
11
12use crate::error::FaucetError;
13use serde_json::{Map, Value};
14
15/// The worksheet a written workbook gets when the config names none.
16pub const DEFAULT_SHEET: &str = "Sheet1";
17
18/// Parse a workbook into records.
19///
20/// `sheet` is a worksheet name, or an index as a string; `header_row` is the
21/// 0-based row supplying field names. Cells keep their Excel type — a number
22/// stays a JSON number — rather than being stringified, because a spreadsheet
23/// is one of the few sources that actually knows its types.
24pub fn decode(
25    bytes: &[u8],
26    sheet: Option<&str>,
27    header_row: usize,
28) -> Result<Vec<Value>, FaucetError> {
29    use calamine::{Reader, Xlsx};
30    // Borrow the body rather than copying it: `&[u8]` is `Read + Seek`, so a
31    // `to_vec()` here would be a second whole-file allocation on top of the one
32    // calamine makes.
33    let mut wb: Xlsx<_> = calamine::open_workbook_from_rs(std::io::Cursor::new(bytes))
34        .map_err(|e| FaucetError::Source(format!("xlsx: opening workbook: {e}")))?;
35    let names = wb.sheet_names().to_vec();
36    let name = resolve_sheet(&names, sheet)?;
37    let name_of_sheet = name.clone();
38    let range = wb
39        .worksheet_range(&name)
40        .map_err(|e| FaucetError::Source(format!("xlsx: reading sheet '{name}': {e}")))?;
41    // An empty sheet is zero records, not an error: a sink that wrote an empty
42    // page produces exactly this, and failing the read would turn "no data"
43    // into a pipeline failure.
44    let total_rows = range.rows().count();
45    if total_rows == 0 {
46        return Ok(Vec::new());
47    }
48    let headers: Vec<String> = range
49        .rows()
50        .nth(header_row)
51        .ok_or_else(|| {
52            FaucetError::Source(format!(
53                "xlsx: header_row {header_row} is beyond the sheet ({total_rows} rows)"
54            ))
55        })?
56        .iter()
57        .enumerate()
58        .map(|(i, c)| {
59            let label = cell_to_string(c);
60            if label.is_empty() {
61                format!("column_{i}")
62            } else {
63                label
64            }
65        })
66        .collect();
67    // Rows are keyed by header, so a repeated (or blank-defaulted) label would
68    // silently drop a column (CORE-37) — the CSV reader's rule.
69    let mut seen = std::collections::HashMap::with_capacity(headers.len());
70    for (i, name) in headers.iter().enumerate() {
71        if let Some(first) = seen.insert(name.as_str(), i) {
72            return Err(FaucetError::Source(format!(
73                "xlsx: sheet '{name_of_sheet}' repeats the header `{name}` at columns {first} \
74                 and {i}; rows are keyed by header, so one column would be lost — rename it"
75            )));
76        }
77    }
78    // A calamine range is rectangular, so every row is exactly as wide as the
79    // header and each cell has its own (now unique) key.
80    let mut out = Vec::new();
81    for row in range.rows().skip(header_row + 1) {
82        let obj: Map<String, Value> = headers
83            .iter()
84            .cloned()
85            .zip(row.iter().map(cell_to_value))
86            .collect();
87        out.push(Value::Object(obj));
88    }
89    Ok(out)
90}
91
92/// A name, an index-as-string, or the first sheet.
93///
94/// A name that looks like a number is tried as a name **first**, so a worksheet
95/// genuinely called "2024" is not read as sheet index 2024 (or, worse, as a
96/// different sheet that happens to be at that index).
97fn resolve_sheet(names: &[String], sheet: Option<&str>) -> Result<String, FaucetError> {
98    match sheet {
99        Some(s) if names.iter().any(|n| n == s) => Ok(s.to_string()),
100        Some(s) => match s.parse::<usize>() {
101            Ok(idx) => names.get(idx).cloned().ok_or_else(|| {
102                FaucetError::Source(format!(
103                    "xlsx: sheet index {idx} out of range ({} sheets)",
104                    names.len()
105                ))
106            }),
107            Err(_) => Err(FaucetError::Source(format!(
108                "xlsx: sheet '{s}' not found (available: {})",
109                names.join(", ")
110            ))),
111        },
112        None => names
113            .first()
114            .cloned()
115            .ok_or_else(|| FaucetError::Source("xlsx: workbook has no worksheets".into())),
116    }
117}
118
119/// Write records as a single-worksheet workbook.
120///
121/// Columns are the first-seen union of every record's keys, so a record that
122/// gains a field mid-page widens the sheet rather than losing the field.
123/// Numbers and booleans are written as their Excel types; everything else is
124/// text, with nested structure re-serialized as JSON rather than dropped.
125pub fn encode(records: &[Value], sheet: Option<&str>) -> Result<Vec<u8>, FaucetError> {
126    use rust_xlsxwriter::{Workbook, Worksheet};
127    let headers = super::header_union(records);
128    let mut book = Workbook::new();
129    let mut ws = Worksheet::new();
130    ws.set_name(sheet.unwrap_or(DEFAULT_SHEET))
131        .map_err(|e| FaucetError::Sink(format!("xlsx: naming the worksheet: {e}")))?;
132
133    let err = |e: rust_xlsxwriter::XlsxError| FaucetError::Sink(format!("xlsx: writing: {e}"));
134    for (col, h) in headers.iter().enumerate() {
135        ws.write_string(0, col as u16, h.as_str()).map_err(err)?;
136    }
137    for (row, r) in records.iter().enumerate() {
138        let row = (row + 1) as u32;
139        for (col, h) in headers.iter().enumerate() {
140            let col = col as u16;
141            match r.get(h) {
142                None | Some(Value::Null) => {}
143                Some(Value::Bool(b)) => {
144                    ws.write_boolean(row, col, *b).map_err(err)?;
145                }
146                Some(Value::Number(n)) => match exact_f64(n) {
147                    Some(f) => {
148                        ws.write_number(row, col, f).map_err(err)?;
149                    }
150                    // Not exactly representable as a double; write the exact
151                    // digits as text instead.
152                    None => {
153                        ws.write_string(row, col, n.to_string().as_str())
154                            .map_err(err)?;
155                    }
156                },
157                Some(other) => {
158                    ws.write_string(row, col, super::cell_text(other).as_str())
159                        .map_err(err)?;
160                }
161            }
162        }
163    }
164    book.push_worksheet(ws);
165    book.save_to_buffer()
166        .map_err(|e| FaucetError::Sink(format!("xlsx: finishing the workbook: {e}")))
167}
168
169/// The double form of `n`, but only when that conversion is **exact**.
170///
171/// xlsx stores every number as an IEEE double, so an integer past 2^53 has no
172/// exact cell representation: writing 9007199254740993 as a number stores
173/// 9007199254740992 and the run reports success. That is silent corruption of
174/// exactly the values — bigint ids, ledger amounts in minor units — most
175/// likely to be beyond the range. Returning `None` routes them to text, which
176/// keeps every digit; the caller's read-back then yields a string rather than
177/// a wrong number, which is visible.
178fn exact_f64(n: &serde_json::Number) -> Option<f64> {
179    /// Largest integer with an exact f64 (2^53).
180    const MAX_EXACT: i64 = 9_007_199_254_740_992;
181    if let Some(i) = n.as_i64() {
182        return (-MAX_EXACT..=MAX_EXACT).contains(&i).then_some(i as f64);
183    }
184    if let Some(u) = n.as_u64() {
185        return (u <= MAX_EXACT as u64).then_some(u as f64);
186    }
187    // A float literal is already a double; nothing is lost by writing it.
188    n.as_f64()
189}
190
191/// An integral double becomes an integer; anything else stays a float.
192fn float_to_value(f: f64) -> Value {
193    if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
194        return Value::from(f as i64);
195    }
196    serde_json::Number::from_f64(f)
197        .map(Value::Number)
198        .unwrap_or(Value::Null)
199}
200
201/// An Excel date/time cell as text: `YYYY-MM-DD` for a whole day, an ISO
202/// timestamp otherwise, an ISO 8601 duration for a time span. The workbook's
203/// 1904 date system is honoured. A serial outside chrono's range keeps the
204/// raw number.
205fn excel_datetime_text(dt: &calamine::ExcelDateTime) -> String {
206    if dt.is_duration() {
207        return dt
208            .as_duration()
209            .map_or_else(|| dt.as_f64().to_string(), |d| d.to_string());
210    }
211    match dt.as_datetime() {
212        Some(t) if t.time() == chrono::NaiveTime::MIN => t.date().to_string(),
213        Some(t) => t.format("%Y-%m-%dT%H:%M:%S%.f").to_string(),
214        None => dt.as_f64().to_string(),
215    }
216}
217
218fn cell_to_string(cell: &calamine::Data) -> String {
219    use calamine::Data;
220    match cell {
221        Data::String(s) => s.clone(),
222        Data::Empty => String::new(),
223        Data::DateTime(dt) => excel_datetime_text(dt),
224        other => other.to_string(),
225    }
226}
227
228fn cell_to_value(cell: &calamine::Data) -> Value {
229    use calamine::Data;
230    match cell {
231        Data::Empty => Value::Null,
232        Data::String(s) => Value::String(s.clone()),
233        Data::Bool(b) => Value::Bool(*b),
234        Data::Int(i) => Value::from(*i),
235        // xlsx stores every number as a double, so an integer and its float
236        // form are the same cell. Mapping an integral double back to an
237        // integer is what makes a write→read round trip faithful; without it
238        // every count comes back as `1.0`.
239        Data::Float(f) => float_to_value(*f),
240        Data::DateTime(dt) => Value::String(excel_datetime_text(dt)),
241        Data::DateTimeIso(s) | Data::DurationIso(s) => Value::String(s.clone()),
242        Data::Error(e) => Value::String(format!("{e:?}")),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use serde_json::json;
250
251    #[test]
252    fn a_workbook_round_trips_with_its_types() {
253        let recs = vec![
254            json!({"id": 1, "name": "ada", "ok": true}),
255            json!({"id": 2, "name": "grace", "ok": false}),
256        ];
257        let bytes = encode(&recs, None).expect("encode");
258        let back = decode(&bytes, None, 0).expect("decode");
259        assert_eq!(back, recs, "numbers and booleans survive as themselves");
260    }
261
262    #[test]
263    fn columns_are_the_union_and_a_missing_cell_is_null() {
264        let bytes = encode(&[json!({"a": 1}), json!({"b": 2})], None).expect("encode");
265        let back = decode(&bytes, None, 0).expect("decode");
266        assert_eq!(
267            back,
268            vec![json!({"a": 1, "b": null}), json!({"a": null, "b": 2})]
269        );
270    }
271
272    #[test]
273    fn repeated_or_colliding_headers_are_refused() {
274        let bytes = encode(
275            &[json!({"a": "x", "b": "x"}), json!({"a": 1, "b": 2})],
276            None,
277        )
278        .expect("encode");
279        let err = decode(&bytes, None, 1).expect_err("dup").to_string();
280        assert!(err.contains("`x` at columns 0 and 1"), "{err}");
281        // A blank header defaults to `column_<i>`, which may collide with a real one.
282        let bytes = encode(
283            &[json!({"a": null, "b": "column_0"}), json!({"a": 1, "b": 2})],
284            None,
285        )
286        .expect("encode");
287        let err = decode(&bytes, None, 1).expect_err("blank").to_string();
288        assert!(err.contains("`column_0`"), "{err}");
289    }
290
291    #[test]
292    fn a_named_sheet_is_written_and_selected_back() {
293        let bytes = encode(&[json!({"a": 1})], Some("Data")).expect("encode");
294        assert!(decode(&bytes, Some("Data"), 0).is_ok());
295        // An index selects it too.
296        assert!(decode(&bytes, Some("0"), 0).is_ok());
297        let err = decode(&bytes, Some("Nope"), 0).expect_err("missing sheet");
298        assert!(err.to_string().contains("available: Data"), "{err}");
299    }
300
301    #[test]
302    fn an_empty_workbook_reads_back_as_zero_records_not_an_error() {
303        // A sink that wrote an empty page produces exactly this; failing here
304        // would turn "no data" into a pipeline failure.
305        let bytes = encode(&[], None).expect("encode");
306        assert!(decode(&bytes, None, 0).expect("decode").is_empty());
307    }
308
309    #[test]
310    fn a_header_row_beyond_the_sheet_is_an_error_naming_the_row() {
311        let bytes = encode(&[json!({"a": 1})], None).expect("encode");
312        let err = decode(&bytes, None, 99).expect_err("out of range");
313        assert!(err.to_string().contains("header_row 99"), "{err}");
314    }
315
316    #[test]
317    fn nested_structure_is_kept_as_json_text_rather_than_dropped() {
318        let bytes = encode(&[json!({"a": {"k": 1}})], None).expect("encode");
319        let back = decode(&bytes, None, 0).expect("decode");
320        assert_eq!(back, vec![json!({"a": r#"{"k":1}"#})]);
321    }
322
323    #[test]
324    fn a_sheet_index_out_of_range_names_the_count() {
325        let bytes = encode(&[json!({"a": 1})], None).expect("encode");
326        let err = decode(&bytes, Some("7"), 0).expect_err("index 7");
327        assert!(err.to_string().contains("out of range"), "{err}");
328    }
329
330    #[test]
331    fn a_non_string_header_cell_is_stringified_rather_than_dropped() {
332        // A worksheet whose header row holds numbers still names its columns;
333        // dropping them would silently shift every value.
334        let bytes = encode(&[json!({"2024": "q1", "flag": true})], None).expect("encode");
335        let back = decode(&bytes, None, 0).expect("decode");
336        assert_eq!(back, vec![json!({"2024": "q1", "flag": true})]);
337    }
338
339    #[test]
340    fn a_blank_cell_reads_back_as_null() {
341        let bytes = encode(&[json!({"a": 1, "b": null})], None).expect("encode");
342        let back = decode(&bytes, None, 0).expect("decode");
343        assert_eq!(back, vec![json!({"a": 1, "b": null})]);
344    }
345
346    #[test]
347    fn not_a_workbook_is_an_error_not_a_panic() {
348        let err = decode(b"id,name\n1,ada\n", None, 0).expect_err("csv is not xlsx");
349        assert!(err.to_string().contains("opening workbook"), "{err}");
350    }
351
352    #[test]
353    fn an_integral_double_reads_back_as_an_integer() {
354        // xlsx has only doubles, so without this a round-tripped count comes
355        // back as `1.0` and every downstream equality check breaks.
356        assert_eq!(float_to_value(1.0), json!(1));
357        assert_eq!(float_to_value(-3.0), json!(-3));
358        assert_eq!(float_to_value(1.5), json!(1.5));
359        assert_eq!(float_to_value(f64::NAN), Value::Null);
360    }
361
362    #[test]
363    fn a_sheet_named_like_a_number_is_matched_by_name_first() {
364        let bytes = encode(&[json!({"a": 1})], Some("2024")).expect("encode");
365        // Without the name-first rule this would be read as index 2024.
366        assert!(decode(&bytes, Some("2024"), 0).is_ok());
367    }
368
369    /// Every `calamine::Data` variant maps to a JSON value. xlsx has no type
370    /// system of its own beyond these, so an unmapped variant would silently
371    /// become the wrong JSON type on read-back.
372    #[test]
373    fn every_cell_variant_decodes_to_its_json_shape() {
374        use calamine::{CellErrorType, Data, ExcelDateTime, ExcelDateTimeType};
375        assert_eq!(cell_to_value(&Data::Empty), Value::Null);
376        assert_eq!(cell_to_value(&Data::String("s".into())), json!("s"));
377        assert_eq!(cell_to_value(&Data::Bool(true)), json!(true));
378        assert_eq!(cell_to_value(&Data::Int(-7)), json!(-7));
379        assert_eq!(cell_to_value(&Data::Float(2.5)), json!(2.5));
380        // An integral double comes back as an integer, not `3.0`.
381        assert_eq!(cell_to_value(&Data::Float(3.0)), json!(3));
382        assert_eq!(
383            cell_to_value(&Data::DateTimeIso("2026-01-02T03:04:05".into())),
384            json!("2026-01-02T03:04:05")
385        );
386        assert_eq!(
387            cell_to_value(&Data::DurationIso("PT1H".into())),
388            json!("PT1H")
389        );
390        // An error cell becomes text rather than being dropped, so a bad cell
391        // is visible downstream instead of vanishing.
392        assert_eq!(
393            cell_to_value(&Data::Error(CellErrorType::Div0)),
394            json!("Div0")
395        );
396        let dt = |v, ty, is_1904| Data::DateTime(ExcelDateTime::new(v, ty, is_1904));
397        assert_eq!(
398            cell_to_value(&dt(45000.5, ExcelDateTimeType::DateTime, false)),
399            json!("2023-03-15T12:00:00")
400        );
401        assert_eq!(
402            cell_to_value(&dt(45000.0, ExcelDateTimeType::DateTime, false)),
403            json!("2023-03-15")
404        );
405        assert_eq!(
406            cell_to_value(&dt(43538.0, ExcelDateTimeType::DateTime, true)),
407            json!("2023-03-15"),
408            "the 1904 date system is honoured"
409        );
410        assert_eq!(
411            cell_to_value(&dt(1.5, ExcelDateTimeType::TimeDelta, false)),
412            json!("PT129600S")
413        );
414        assert_eq!(
415            cell_to_string(&dt(45000.0, ExcelDateTimeType::DateTime, false)),
416            "2023-03-15"
417        );
418        assert_eq!(
419            cell_to_value(&dt(1e12, ExcelDateTimeType::DateTime, false)),
420            json!("1000000000000")
421        );
422    }
423
424    /// Header cells are read as text. An empty header must be the empty
425    /// string, not the word "Empty" — that string becomes a record key.
426    #[test]
427    fn header_cells_stringify_without_leaking_the_debug_form() {
428        use calamine::Data;
429        assert_eq!(cell_to_string(&Data::String("name".into())), "name");
430        assert_eq!(cell_to_string(&Data::Empty), "");
431        assert_eq!(cell_to_string(&Data::Int(3)), "3");
432        assert_eq!(cell_to_string(&Data::Bool(true)), "true");
433    }
434
435    /// `float_to_value` is what keeps a write→read round trip faithful; a
436    /// non-finite double has no JSON number form and must not panic.
437    #[test]
438    fn a_non_finite_double_becomes_null_rather_than_panicking() {
439        assert_eq!(float_to_value(f64::NAN), Value::Null);
440        assert_eq!(float_to_value(f64::INFINITY), Value::Null);
441        assert_eq!(float_to_value(-0.0), json!(0));
442    }
443
444    /// The guard that decides number-vs-text. Every branch matters: an i64
445    /// past 2^53, a u64 past it, and a plain float that is always exact.
446    #[test]
447    fn only_exactly_representable_numbers_are_written_as_numbers() {
448        use serde_json::Number;
449        let n = |v: &str| -> Number { serde_json::from_str(v).expect("number") };
450        assert_eq!(exact_f64(&n("0")), Some(0.0));
451        assert_eq!(exact_f64(&n("-1")), Some(-1.0));
452        assert_eq!(exact_f64(&n("9007199254740992")), Some(9007199254740992.0));
453        assert_eq!(
454            exact_f64(&n("-9007199254740992")),
455            Some(-9007199254740992.0)
456        );
457        // One past 2^53 in each direction has no exact double.
458        assert_eq!(exact_f64(&n("9007199254740993")), None);
459        assert_eq!(exact_f64(&n("-9007199254740993")), None);
460        assert_eq!(exact_f64(&n("9223372036854775807")), None);
461        // A u64 beyond i64::MAX takes the unsigned branch.
462        assert_eq!(exact_f64(&n("18446744073709551615")), None);
463        // A float literal is already a double, so nothing is lost.
464        assert_eq!(exact_f64(&n("1.5")), Some(1.5));
465        assert_eq!(exact_f64(&n("1e300")), Some(1e300));
466    }
467
468    /// The regression itself, end to end: a bigint must survive as digits.
469    /// Writing it as a number stores 9007199254740992 and reports success —
470    /// silent corruption of exactly the ids most likely to be that large.
471    #[test]
472    fn an_integer_past_2_53_keeps_its_digits_instead_of_rounding() {
473        let recs = vec![json!({ "id": 9007199254740993i64, "small": 42 })];
474        let bytes = encode(&recs, None).expect("encode");
475        let back = decode(&bytes, None, 0).expect("decode");
476        assert_eq!(back.len(), 1);
477        assert_eq!(
478            back[0]["id"],
479            Value::String("9007199254740993".into()),
480            "the exact digits must survive, as text"
481        );
482        // A value inside the exact range is still a typed number.
483        assert_eq!(back[0]["small"], json!(42));
484    }
485
486    /// #789 FILE-07: a date-formatted cell in a real workbook reads back as a
487    /// date, not the serial number.
488    #[test]
489    fn a_date_cell_reads_back_as_a_date() {
490        use rust_xlsxwriter::{ExcelDateTime, Format, Workbook};
491        let mut wb = Workbook::new();
492        let ws = wb.add_worksheet();
493        ws.write_string(0, 0, "day").unwrap();
494        ws.write_string(0, 1, "at").unwrap();
495        let date = Format::new().set_num_format("yyyy-mm-dd");
496        let stamp = Format::new().set_num_format("yyyy-mm-dd hh:mm:ss");
497        ws.write_datetime_with_format(1, 0, ExcelDateTime::from_ymd(2023, 3, 15).unwrap(), &date)
498            .unwrap();
499        ws.write_datetime_with_format(
500            1,
501            1,
502            ExcelDateTime::parse_from_str("2023-03-15 12:30:00").unwrap(),
503            &stamp,
504        )
505        .unwrap();
506        let bytes = wb.save_to_buffer().unwrap();
507        assert_eq!(
508            decode(&bytes, None, 0).unwrap(),
509            vec![json!({"day": "2023-03-15", "at": "2023-03-15T12:30:00"})]
510        );
511    }
512}