miniexcel 0.1.0

Experimental Rust XLSX reader and writer for MiniExcel
Documentation
use miniexcel::{
    AggregateOp, AggregateSpec, CellValue, ComparisonOp, DynamicRow, FilterExpr, HeaderMode,
    MiniExcel, QueryLiteral, QueryPlan, ReadOptions, WriteOptions,
};
use tempfile::NamedTempFile;

fn workbook() -> NamedTempFile {
    let file = NamedTempFile::new().expect("create workbook path");
    let rows = [
        row("Hardware", "East", true, CellValue::Int(10)),
        row("Hardware", "East", true, CellValue::Float(12.5)),
        row("Software", "West", false, CellValue::Int(40)),
        row("Software", "West", true, CellValue::Int(20)),
        row("Hardware", "West", true, CellValue::Empty),
    ];
    MiniExcel::save_as_with_options(
        file.path(),
        &rows,
        &WriteOptions::new().with_sheet_name("Sales"),
    )
    .expect("write workbook");
    file
}

fn row(category: &str, region: &str, active: bool, amount: CellValue) -> DynamicRow {
    let mut row = DynamicRow::new();
    row.insert("Category".to_owned(), CellValue::String(category.to_owned()));
    row.insert("Region".to_owned(), CellValue::String(region.to_owned()));
    row.insert("Active".to_owned(), CellValue::Bool(active));
    row.insert("Amount".to_owned(), amount);
    row
}

fn read_options() -> ReadOptions {
    ReadOptions::new().with_sheet_name("Sales").with_header_mode(HeaderMode::FirstRow)
}

fn grouped_plan() -> QueryPlan {
    QueryPlan::new([
        AggregateSpec::count_all("rows"),
        AggregateSpec::column(AggregateOp::Count, "Amount", "amountCount"),
        AggregateSpec::column(AggregateOp::Sum, "Amount", "total"),
        AggregateSpec::column(AggregateOp::Average, "Amount", "average"),
        AggregateSpec::column(AggregateOp::Min, "Amount", "minimum"),
        AggregateSpec::column(AggregateOp::Max, "Amount", "maximum"),
    ])
    .with_filter(FilterExpr::and([
        FilterExpr::compare("Active", ComparisonOp::Eq, QueryLiteral::Bool(true)),
        FilterExpr::or([
            FilterExpr::compare(
                "Region",
                ComparisonOp::Eq,
                QueryLiteral::String("East".to_owned()),
            ),
            FilterExpr::compare(
                "Region",
                ComparisonOp::Eq,
                QueryLiteral::String("West".to_owned()),
            ),
        ]),
    ]))
    .with_group_by(["Category", "Region"])
}

#[test]
fn grouped_analysis_matches_for_paths_and_bytes() {
    let file = workbook();
    let plan = grouped_plan();
    let path_result =
        MiniExcel::analyze_with_options(file.path(), &read_options(), &plan).expect("analyze path");
    let bytes = std::fs::read(file.path()).expect("read workbook bytes");
    let byte_result =
        MiniExcel::analyze_bytes(&bytes, &read_options(), &plan).expect("analyze workbook bytes");

    assert_eq!(byte_result, path_result);
    assert_eq!(path_result.sheet_name(), "Sales");
    assert_eq!(path_result.stats().seen_rows(), 5);
    assert_eq!(path_result.stats().matched_rows(), 4);
    assert_eq!(path_result.stats().total_groups(), 3);
    assert!(!path_result.stats().truncated());

    let groups = path_result.rows();
    assert_eq!(groups[0].values()["Category"], CellValue::String("Hardware".to_owned()));
    assert_eq!(groups[0].values()["Region"], CellValue::String("East".to_owned()));
    assert_eq!(groups[0].values()["rows"], CellValue::Int(2));
    assert_eq!(groups[0].values()["amountCount"], CellValue::Int(2));
    assert_eq!(groups[0].values()["total"], CellValue::Float(22.5));
    assert_eq!(groups[0].values()["average"], CellValue::Float(11.25));
    assert_eq!(groups[0].values()["minimum"], CellValue::Int(10));
    assert_eq!(groups[0].values()["maximum"], CellValue::Float(12.5));
    assert_eq!(groups[0].source_rows(), [2, 3]);

    assert_eq!(groups[1].values()["Category"], CellValue::String("Software".to_owned()));
    assert_eq!(groups[1].values()["total"], CellValue::Int(20));
    assert_eq!(groups[2].values()["Region"], CellValue::String("West".to_owned()));
    assert_eq!(groups[2].values()["amountCount"], CellValue::Int(0));
    assert!(groups[2].values()["total"].is_empty());
}

#[test]
fn global_analysis_emits_empty_aggregate_row() {
    let file = workbook();
    let plan = QueryPlan::new([
        AggregateSpec::count_all("rows"),
        AggregateSpec::column(AggregateOp::Sum, "Amount", "total"),
    ])
    .with_filter(FilterExpr::compare(
        "Category",
        ComparisonOp::Eq,
        QueryLiteral::String("Missing".to_owned()),
    ));

    let result = MiniExcel::analyze_with_options(file.path(), &read_options(), &plan)
        .expect("analyze empty selection");
    assert_eq!(result.rows().len(), 1);
    assert_eq!(result.rows()[0].values()["rows"], CellValue::Int(0));
    assert!(result.rows()[0].values()["total"].is_empty());
    assert_eq!(result.stats().matched_rows(), 0);
}

#[test]
fn group_limit_fails_deterministically() {
    let file = workbook();
    let plan = QueryPlan::new([AggregateSpec::count_all("rows")])
        .with_group_by(["Category", "Region"])
        .with_max_groups(2);

    let error = MiniExcel::analyze_with_options(file.path(), &read_options(), &plan)
        .expect_err("third group should exceed the limit");
    assert!(error.to_string().contains("exceeded max_groups (2)"));
}

#[test]
fn query_plan_has_a_versioned_json_contract() {
    let json = serde_json::to_value(grouped_plan()).expect("serialize query plan");
    assert_eq!(json["version"], "miniexcel.query-plan/v1");
    assert_eq!(json["maxGroups"], 10_000);
    assert_eq!(json["aggregates"][0]["op"], "count");
    assert_eq!(json["filter"]["kind"], "and");
}