miniexcel 0.4.0

Streaming XLSX and CSV reader and writer for Rust
Documentation
use std::io::{Cursor, Write};

use miniexcel::{CellValue, MiniExcel};
use serde::Deserialize;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};

mod common;

#[derive(Debug, Deserialize, PartialEq)]
struct TableRecord {
    #[serde(rename = "MetaName")]
    name: Option<String>,
    #[serde(rename = "MetaValue")]
    value: Option<i64>,
}

#[derive(Debug, Deserialize, PartialEq)]
struct TrimmedRecord {
    #[serde(rename = "MetaName")]
    name: Option<String>,
    #[serde(rename = "MetaValue")]
    value: Option<i64>,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
struct DotNetTableRecord {
    #[serde(rename = "Col1")]
    col1: String,
    #[serde(rename = "Col2")]
    col2: i64,
    #[serde(rename = "Col3", deserialize_with = "miniexcel::serde_helpers::deserialize_date")]
    col3: chrono::NaiveDate,
}

#[test]
fn matches_dotnet_query_table_reference_fixture() {
    let path = common::fixture("TestQueryTable.xlsx");
    let table1 = MiniExcel::query_table(&path, "table1", None)
        .unwrap()
        .collect::<miniexcel::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(table1.len(), 3);
    assert_eq!(table1[0]["Col1"], CellValue::String("aaa".to_owned()));
    assert_eq!(table1[0]["Col2"], CellValue::Int(123));

    let typed = MiniExcel::query_table_as::<DotNetTableRecord>(&path, "Table1", None)
        .unwrap()
        .collect::<miniexcel::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(typed.len(), 3);
    assert_eq!(typed[2].col1, "ccc");
    assert_eq!(typed[2].col2, 789);
    assert_eq!(typed[2].col3, chrono::NaiveDate::from_ymd_opt(2026, 5, 19).unwrap());

    let table2 = MiniExcel::query_table(&path, "Table2", Some("Sheet1"))
        .unwrap()
        .collect::<miniexcel::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(table2.len(), 2);
    assert_eq!(table2[0]["Prop1"], CellValue::String("test".to_owned()));
    assert_eq!(table2[0]["Prop2"], CellValue::Int(11));
    assert_eq!(table2[0]["Prop3"], CellValue::String("aaa".to_owned()));

    assert!(
        MiniExcel::query_table(&path, "CustomTable", Some("CustomSheet"))
            .unwrap()
            .next()
            .transpose()
            .unwrap()
            .is_some()
    );

    let bytes = std::fs::read(&path).unwrap();
    assert_eq!(MiniExcel::query_table_bytes(&bytes, "Table1", None).unwrap(), table1);
    let mut reader = Cursor::new(bytes);
    let mut borrowed = Vec::new();
    MiniExcel::visit_table_rows_as_from_reader::<DotNetTableRecord, _, _>(
        &mut reader,
        "Table1",
        None,
        |_, row| {
            borrowed.push(row.clone());
            Ok(true)
        },
    )
    .unwrap();
    assert_eq!(borrowed, typed);
}

#[test]
fn queries_named_table_with_metadata_headers_and_bounds() {
    let directory = tempfile::tempdir().unwrap();
    let path = directory.path().join("tables.xlsx");
    let bytes = table_fixture("DataTable", "B2:C5", None, "MetaName", "MetaValue");
    std::fs::write(&path, &bytes).unwrap();

    let rows = MiniExcel::query_table(&path, "datatable", None)
        .unwrap()
        .collect::<miniexcel::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(rows.len(), 3);
    assert_eq!(rows[0].keys().map(String::as_str).collect::<Vec<_>>(), ["MetaName", "MetaValue"]);
    assert_eq!(rows[0]["MetaName"], CellValue::String("Alpha".to_owned()));
    assert_eq!(rows[0]["MetaValue"], CellValue::Int(1));
    assert!(rows[1].values().all(CellValue::is_empty));
    assert_eq!(rows[2]["MetaName"], CellValue::String("Total".to_owned()));
    assert_eq!(rows[2]["MetaValue"], CellValue::Int(3));

    let typed = MiniExcel::query_table_as::<TableRecord>(&path, "DataTable", Some("Data"))
        .unwrap()
        .collect::<miniexcel::Result<Vec<_>>>()
        .unwrap();
    assert_eq!(typed[0], TableRecord { name: Some("Alpha".to_owned()), value: Some(1) });
    assert_eq!(typed[1], TableRecord { name: None, value: None });

    assert_eq!(MiniExcel::query_table_bytes(&bytes, "DataTable", None).unwrap(), rows);

    let mut reader = Cursor::new(bytes);
    let mut borrowed = Vec::new();
    let summary = MiniExcel::visit_table_rows_from_reader(
        &mut reader,
        "DataTable",
        Some("Data"),
        |excel_row, row| {
            borrowed.push((excel_row, row.clone()));
            Ok(true)
        },
    )
    .unwrap();
    assert_eq!(summary.sheet_name(), "Data");
    assert_eq!(summary.columns(), ["MetaName", "MetaValue"]);
    assert_eq!(summary.visited_rows(), 3);
    assert_eq!(borrowed.iter().map(|(row, _)| *row).collect::<Vec<_>>(), [3, 4, 5]);
}

#[test]
fn headerless_table_includes_first_referenced_row() {
    let bytes = table_fixture("RawTable", "B2:C3", Some(0), "First", "Second");
    let rows = MiniExcel::query_table_bytes(&bytes, "RawTable", None).unwrap();
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0]["First"], CellValue::String("PhysicalName".to_owned()));
    assert_eq!(rows[0]["Second"], CellValue::String("PhysicalValue".to_owned()));
}

#[test]
fn missing_and_malformed_tables_return_specific_errors() {
    let bytes = table_fixture("DataTable", "B2:C5", None, "Name", "Value");
    let missing = MiniExcel::query_table_bytes(&bytes, "Missing", None).unwrap_err();
    assert!(missing.to_string().contains("table 'Missing' was not found"));
    let display_name =
        MiniExcel::query_table_bytes(&bytes, "DifferentDisplayName", None).unwrap_err();
    assert!(display_name.to_string().contains("table 'DifferentDisplayName' was not found"));

    let directory = tempfile::tempdir().unwrap();
    let path = directory.path().join("table.xlsx");
    std::fs::write(&path, &bytes).unwrap();
    let missing_sheet = MiniExcel::query_table(&path, "DataTable", Some("MissingSheet"))
        .err()
        .expect("missing sheet should fail");
    assert!(missing_sheet.to_string().contains("worksheet 'MissingSheet' was not found"));

    let malformed = table_fixture("DataTable", "B2", None, "Name", "Value");
    let error = MiniExcel::query_table_bytes(&malformed, "DataTable", None).unwrap_err();
    assert!(error.to_string().contains("table range must contain two cells"));
}

#[test]
fn table_metadata_handles_trim_fallback_and_header_only_ranges() {
    let trimmed = table_fixture("Trimmed", "B2:C3", None, " MetaName ", " MetaValue ");
    let typed =
        MiniExcel::query_table_as::<TrimmedRecord>(write_fixture(&trimmed).path(), "Trimmed", None)
            .unwrap()
            .collect::<miniexcel::Result<Vec<_>>>()
            .unwrap();
    assert_eq!(typed[0], TrimmedRecord { name: Some("Alpha".to_owned()), value: Some(1) });

    let missing_column = table_fixture("Fallback", "B2:C3", None, "OnlyName", "");
    let rows = MiniExcel::query_table_bytes(&missing_column, "Fallback", None).unwrap();
    assert_eq!(rows[0].keys().map(String::as_str).collect::<Vec<_>>(), ["OnlyName", ""]);

    let header_only = table_fixture("Headers", "B2:C2", None, "Name", "Value");
    let mut reader = Cursor::new(header_only);
    let summary = MiniExcel::visit_table_rows_from_reader(&mut reader, "Headers", None, |_, _| {
        panic!("header-only table emitted a data row")
    })
    .unwrap();
    assert_eq!(summary.columns(), ["Name", "Value"]);
    assert_eq!(summary.visited_rows(), 0);
}

fn write_fixture(bytes: &[u8]) -> tempfile::NamedTempFile {
    let mut file = tempfile::NamedTempFile::new().unwrap();
    file.write_all(bytes).unwrap();
    file
}

fn table_fixture(
    table_name: &str,
    reference: &str,
    header_row_count: Option<u32>,
    first_header: &str,
    second_header: &str,
) -> Vec<u8> {
    let header_attribute =
        header_row_count.map(|count| format!(" headerRowCount=\"{count}\"")).unwrap_or_default();
    let table = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?><table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="1" name="{table_name}" displayName="DifferentDisplayName" ref="{reference}"{header_attribute}><tableColumns count="2"><tableColumn id="1" name="{first_header}"/><tableColumn id="2" name="{second_header}"/></tableColumns></table>"#
    );
    let entries = [
        (
            "[Content_Types].xml",
            br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/tables/nonstandard.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/></Types>"#.as_slice(),
        ),
        (
            "_rels/.rels",
            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#.as_slice(),
        ),
        (
            "xl/workbook.xml",
            br#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets></workbook>"#.as_slice(),
        ),
        (
            "xl/_rels/workbook.xml.rels",
            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/custom.xml"/></Relationships>"#.as_slice(),
        ),
        (
            "xl/worksheets/custom.xml",
            br#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><dimension ref="A1:D6"/><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Outside</t></is></c></row><row r="2"><c r="B2" t="inlineStr"><is><t>PhysicalName</t></is></c><c r="C2" t="inlineStr"><is><t>PhysicalValue</t></is></c></row><row r="3"><c r="B3" t="inlineStr"><is><t>Alpha</t></is></c><c r="C3"><v>1</v></c></row><row r="4"/><row r="5"><c r="B5" t="inlineStr"><is><t>Total</t></is></c><c r="C5"><v>3</v></c></row><row r="6"><c r="D6"><v>999</v></c></row></sheetData><tableParts count="1"><tablePart r:id="tableRel"/></tableParts></worksheet>"#.as_slice(),
        ),
        (
            "xl/worksheets/_rels/custom.xml.rels",
            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="tableRel" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/nonstandard.xml"/></Relationships>"#.as_slice(),
        ),
        ("xl/tables/nonstandard.xml", table.as_bytes()),
    ];

    let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
    for (name, payload) in entries {
        writer.start_file(name, options).unwrap();
        writer.write_all(payload).unwrap();
    }
    writer.finish().unwrap().into_inner()
}