diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba27c934..9e16ff7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -135,7 +135,22 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./TestResults/*.cobertura.xml
flags: csharp
- fail_ci_if_error: false
+ fail_ci_if_error: true
+
+ # The codecov/project checks are required on master, and Codecov posts them only for a commit it
+ # has heard about; when nothing here ran, an empty upload passes them on carried-forward coverage.
+ - name: Checkout for empty Codecov upload
+ if: env.RUN != 'true' && matrix.os == 'ubuntu-latest'
+ uses: actions/checkout@v7
+
+ - name: Empty Codecov upload
+ if: env.RUN != 'true' && matrix.os == 'ubuntu-latest'
+ uses: codecov/codecov-action@v7
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ run_command: empty-upload
+ force: true
+ fail_ci_if_error: true
isa-fallback:
name: Test ISA fallback (${{ matrix.isa }})
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index d0dd6254..c17d96a5 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -53,7 +53,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- target: [xlsx, xlsx-memory, xlsx-differential, xlsx-pipeline, xlsb, xlsb-memory, xlsb-differential, xlsb-pipeline, xls, encrypted, csv, csv-sniff, csv-parallel, inflate]
+ target: [xlsx, xlsx-memory, xlsx-differential, xlsx-pipeline, xlsb, xlsb-memory, xlsb-differential, xlsb-pipeline, xls, encrypted, csv, csv-sniff, csv-parallel, inflate, deflate, deflate-segments]
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -111,11 +111,12 @@ jobs:
encrypted) dict=encrypted ;;
csv*) dict=csv ;;
inflate) dict=inflate ;;
+ deflate*) dict= ;;
esac
./libfuzzer-dotnet \
--target_path=fuzz-out/ExcelReader.Fuzz \
corpus/${{ matrix.target }} \
- -dict="tests/ExcelReader.Fuzz/dictionaries/$dict.dict" \
+ ${dict:+-dict="tests/ExcelReader.Fuzz/dictionaries/$dict.dict"} \
-max_total_time=${{ github.event.inputs.seconds || '600' }} \
-max_len=131072 \
-timeout=25 \
diff --git a/.github/workflows/native-bindings.yml b/.github/workflows/native-bindings.yml
index 7cdea891..1052889f 100644
--- a/.github/workflows/native-bindings.yml
+++ b/.github/workflows/native-bindings.yml
@@ -351,3 +351,24 @@ jobs:
- name: Run the consumer
run: ctest --test-dir build/consumer --output-on-failure -C Release
+
+ - name: Test with coverage and sanitizers (Linux)
+ if: runner.os == 'Linux'
+ env:
+ EXCELREADER_NATIVE_LIB: ${{ steps.lib.outputs.path }}
+ ASAN_OPTIONS: detect_leaks=0
+ UBSAN_OPTIONS: print_stacktrace=1
+ run: |
+ cmake -S cpp -B build/cpp-coverage -DEXCELREADER_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug "-DCMAKE_CXX_FLAGS=--coverage -fprofile-update=atomic -fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer"
+ cmake --build build/cpp-coverage
+ ctest --test-dir build/cpp-coverage --output-on-failure
+ pipx run gcovr --root . --filter 'src/ExcelReader.Native/include/' --exclude-throw-branches --merge-lines --cobertura coverage-cpp.xml --txt
+
+ - name: Upload C++ coverage to Codecov
+ if: runner.os == 'Linux'
+ uses: codecov/codecov-action@v7
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ./coverage-cpp.xml
+ flags: cpp
+ fail_ci_if_error: false
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 09653c1f..bacdfde0 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -175,15 +175,25 @@ the seam. A wrong start also shifts every field boundary, so on a source with qu
discarded pass can hand the callback values that appear nowhere in the file. For an accumulator both are
invisible: the discarded instance is thrown away.
-A guess is only wrong when the offset falls inside a quoted field. `CsvChunkWorker.GuessStart` first
-reads the byte before the chunk start and returns the chunk start unchanged when it already sits on a
-record boundary, because `CsvBoundaryResolver.FindRecordStart` only ever scans past a terminator and
+A guess can only be wrong when the offset falls inside a quoted field, so `CsvChunkWorker.GuessStart`
+first decides whether it does. `CsvBoundaryResolver.InferParity` reads the runs of quotes in a window
+of up to 64 KB from the chunk start, reaching back over a run the start cuts and looking past the
+chunk's end when it has to. Under RFC 4180 an odd run that opens a field follows a delimiter or a line
+start, and one that closes a field precedes a delimiter, a line end or the end of the source, so the
+first run that fits only one of the two decides. When a run fits both, the worker does not guess at
+all: the chunk is left to the merge, which reads it from its confirmed start.
+
+When the chunk starts outside quotes and the byte before it is a terminator, `GuessStart` returns the
+chunk start unchanged, because `CsvBoundaryResolver.FindRecordStart` only ever scans past a terminator and
would otherwise overshoot by a whole record on every chunk that happens to be aligned — which, at a
-1 MiB minimum chunk size, is every chunk of a fixed-width file. That leaves quote-free sources exact,
-and the repeat documented as a caller obligation for the rest rather than designed out. Removing it
-there too needs a quote-parity prefix pass over the bytes before any parsing starts; that pass is a
-barrier and would cost roughly half the dop-16 runtime unless pipelined, which is more machinery than
-the hazard currently warrants.
+1 MiB minimum chunk size, is every chunk of a fixed-width file.
+
+`ForEachAsync` is therefore exactly once on a CSV that follows RFC 4180 quoting with no quoted field
+longer than 64 KB. A stray quote inside an unquoted field, text after a closing quote or a longer quoted
+field can still be misread, and those sources keep the repeat as a documented caller obligation.
+Removing it there too would need a quote-parity prefix pass over the bytes before any parsing starts.
+That pass is a barrier and would cost roughly half the dop-16 runtime unless pipelined, which is more
+machinery than sources that break RFC 4180 warrant.
## Shared plumbing
diff --git a/Directory.Build.props b/Directory.Build.props
index 0e6a30ac..94839ae4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -27,7 +27,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitiveall
-
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
diff --git a/README.md b/README.md
index cb3bd6e6..313ac3de 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,12 @@ High-performance Excel reading and writing for .NET 10. Reads `.xlsx`, `.xlsb`,
ExcelReader is built for streaming spreadsheet workloads where low allocations matter. It reads worksheet rows as lightweight `ref struct` values, resolves shared strings, recognizes date styles, and handles sparse cells. Workbooks open from a path, a `Stream`, or a caller-owned `ReadOnlyMemory` — the in-memory path decompresses parts directly, without a `ZipArchive` or an intermediate stream.
+It moves data, not presentation. Reading gives each cell's value and type, with a formula cell read as
+its last calculated result, plus sheet names, visibility and Excel tables. Formula text, merged cells,
+comments, hyperlinks, charts, images and conditional formatting are not read. Writing covers values,
+column widths and cell styles limited to bold, italic and a number format. To edit an existing workbook
+or produce formatted reports, use a library built for document manipulation.
+
## Install
```bash
@@ -45,7 +51,7 @@ foreach (Row row in workbook.FirstSheet)
- [Reading untrusted files](docs/guide/untrusted-input.md) — the resource limits that bound what one hostile file can cost, and how to reject one cleanly.
- [Migrating to v7](docs/guide/migrating-to-v7.md) — porting `CsvParallel` code to `SheetParallel`, which now reads XLSX and XLSB on several threads too.
- [Migrating to v6](docs/guide/migrating-to-v6.md) — porting v5 reader code to workbooks and sheets, and reading sheets in parallel.
-- [Benchmarks](docs/performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, OfficeIMO and SpreadCheetah. [Live results](https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/).
+- [Benchmarks](docs/performance/benchmarks.md) — throughput and allocation against Sep, Sylvan, ExcelDataReader, OfficeIMO and SpreadCheetah. [Live results](https://gabrielmarquezmatte.github.io/ExcelReader/dev/bench/).
## Command line
diff --git a/codecov.yml b/codecov.yml
index 203f0652..acbd864c 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,3 +1,30 @@
+coverage:
+ status:
+ project:
+ csharp:
+ target: 88%
+ flags:
+ - csharp
+ python:
+ target: 88%
+ flags:
+ - python
+ rust:
+ target: 88%
+ flags:
+ - rust
+ cpp:
+ target: 88%
+ flags:
+ - cpp
+ patch:
+ default:
+ target: 88%
+
+# gcov reports the C++ headers through the cpp/include/xl symlink; git tracks them at their real path.
+fixes:
+ - "cpp/include/xl/::src/ExcelReader.Native/include/"
+
# Each language uploads under its own flag from a path-filtered job, so a flag a commit did not run keeps its last report.
flag_management:
default_rules:
@@ -12,3 +39,6 @@ flag_management:
- name: python
paths:
- python/
+ - name: cpp
+ paths:
+ - src/ExcelReader.Native/include/
diff --git a/cpp/README.md b/cpp/README.md
index a9c7ecc6..33aa3951 100644
--- a/cpp/README.md
+++ b/cpp/README.md
@@ -106,8 +106,23 @@ for (xl::Sheet sheet : *workbook->sheets()) { std::println("{}", sheet.name().va
for (const auto& column : *workbook->sheet(1).infer_schema(1, 100)) {
// column.name is nullopt when the column must be resolved by column.index instead.
}
+
+// With no row type, parse the sheet through that schema. The columns are the xl::ColumnRef
+// write_columns takes, so the table writes back as it was read.
+auto schema = workbook->sheet(1).infer_schema();
+auto table = xl::parse(workbook->sheet(1), *schema);
+for (const xl::ColumnRef& column : table->columns()) { /* column.type, column.values, ... */ }
+auto written = xl::write_columns("copy.xlsx", table->columns());
+
+// Or a batch at a time: an xl::TableReader, whose batches are xl::Tables.
+auto reader = xl::parse_batches(workbook->sheet(1), *schema, {.batch_size = 10'000});
+for (auto& batch : *reader) { if (!batch) { break; } /* batch->columns() */ }
```
+A column matched by index has no name, and writing needs every column named or none. The schema is
+a guess over a sample: a column inferred as `XL_T_I64` that holds a fraction further down fails the
+read, unless the column is `nullable`.
+
Every entry point returns `std::expected` — this header throws nothing.
A workbook hands out sheets. An `xl::Sheet` is the workbook handle and an index: it owns nothing,
@@ -299,6 +314,18 @@ while (true)
Same `header_row`/`batch_size` meaning as `xl::parse_batches` above.
+With no row type, both take the columns at run time instead, such as the ones `infer_schema`
+returned. A column with a name is matched by it, one without by its index:
+
+```cpp
+auto schema = workbook->sheet(0).infer_schema();
+auto table = xl::to_arrow(workbook->sheet(0), *schema);
+auto stream = xl::to_arrow_stream(workbook->sheet(0), *schema, {.batch_size = 10'000});
+```
+
+The schema is a guess over a sample: a column inferred as `XL_T_I64` that holds a fraction further
+down fails the read, unless the column is `nullable`.
+
## Writing
Two layers, mirroring the two on the reading side.
diff --git a/cpp/tests/aggregate.cpp b/cpp/tests/aggregate.cpp
index 366da363..76a1e9e9 100644
--- a/cpp/tests/aggregate.cpp
+++ b/cpp/tests/aggregate.cpp
@@ -473,6 +473,34 @@ static int test_sheet_aggregate_reports_a_throwing_accumulate()
return 0;
}
+static int test_sheet_aggregate_fails_on_a_sheet_past_the_end()
+{
+ const auto bytes = generate(XL_FORMAT_XLSX, 10);
+ auto workbook = open_generated(bytes, XL_FORMAT_XLSX);
+ CHECK(workbook.has_value(), "open the generated workbook");
+ auto result = workbook->sheet(99).aggregate(RowCounter{}, 1, 1);
+ CHECK(!result.has_value() && result.error().code < 0, "a sheet past the end fails with the library's status");
+ return 0;
+}
+
+static int test_aggregate_reports_a_seed_copy_that_throws()
+{
+ struct ThrowsOnCopy
+ {
+ ThrowsOnCopy() = default;
+ ThrowsOnCopy(const ThrowsOnCopy &) { throw std::runtime_error("no copies"); }
+ ThrowsOnCopy(ThrowsOnCopy &&) noexcept = default;
+ int32_t accumulate(xl::RowView) noexcept { return XL_OK; }
+ int32_t combine(ThrowsOnCopy &) noexcept { return XL_OK; }
+ };
+
+ std::span data(reinterpret_cast(kCsv.data()), kCsv.size());
+ auto result = xl::aggregate_csv(data, ThrowsOnCopy{});
+ CHECK(!result.has_value() && std::string_view(result.error().message).find("threw") != std::string_view::npos,
+ "a seed copy that throws must be reported as a callback that threw");
+ return 0;
+}
+
static int test_existing_csv_accumulator_works_unchanged()
{
const auto bytes = generate(XL_FORMAT_CSV, 10'000);
@@ -508,6 +536,14 @@ int main()
{
return failed;
}
+ if (int failed = test_sheet_aggregate_fails_on_a_sheet_past_the_end())
+ {
+ return failed;
+ }
+ if (int failed = test_aggregate_reports_a_seed_copy_that_throws())
+ {
+ return failed;
+ }
if (int failed = test_aggregate_csv_memory())
{
return failed;
diff --git a/cpp/tests/arrow.cpp b/cpp/tests/arrow.cpp
index 73639841..58c10e26 100644
--- a/cpp/tests/arrow.cpp
+++ b/cpp/tests/arrow.cpp
@@ -93,6 +93,20 @@ int main()
auto second = xl::to_arrow_stream(stream_workbook->sheet(0), {.batch_size = 8});
CHECK(second.has_value(), "a second stream on one workbook opens");
+
+ xl::ArrowStream moved = std::move(*second);
+ CHECK(!second->schema().has_value(), "a moved-from stream has no schema");
+ CHECK(!second->next().has_value(), "a moved-from stream reads nothing");
+ CHECK(moved.schema().has_value(), "the moved-to stream still reports its schema");
+
+ const xl::InferredColumn columns[] = {{"Coluna3", 0, XL_T_I64, false}};
+ CHECK(!xl::to_arrow_stream(stream_workbook->sheet(99)).has_value(),
+ "a stream over a sheet past the end fails to open");
+ CHECK(!xl::to_arrow_stream(stream_workbook->sheet(99), columns).has_value(),
+ "a runtime-schema stream over a sheet past the end fails to open");
+ auto zero_batch = xl::to_arrow_stream(stream_workbook->sheet(0), columns, {.batch_size = 0});
+ CHECK(!zero_batch.has_value() && zero_batch.error().code == XL_INVALID_ARGUMENT,
+ "a runtime-schema stream rejects a batch size below 1");
}
{
@@ -199,6 +213,43 @@ int main()
CHECK(reader.has_value(), "a typed reader opens while an Arrow stream is live");
}
+ {
+ auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, options);
+ CHECK(wb.has_value(), "opening a workbook for the inferred-schema test must succeed");
+ auto schema = wb->sheet(0).infer_schema();
+ CHECK(schema.has_value() && !schema->empty(), "infer_schema must find columns");
+
+ auto inferred = xl::to_arrow(wb->sheet(0), *schema);
+ CHECK(inferred.has_value(), "to_arrow over an inferred schema must succeed");
+ CHECK(inferred->schema.n_children == static_cast(schema->size()), "one Arrow column per inferred column");
+ CHECK(inferred->array.length == 100, "the inferred read holds every data row");
+ CHECK(std::strcmp(inferred->schema.children[0]->name, "Coluna1") == 0, "columns keep their header names");
+
+ auto by_index = wb->sheet(0).infer_schema(0);
+ CHECK(by_index.has_value() && !(*by_index)[0].name.has_value(), "a schema without a header has no names");
+ auto unnamed = xl::to_arrow(wb->sheet(0), *by_index, {.header_row = 0});
+ CHECK(unnamed.has_value(), "to_arrow resolves unnamed columns by index");
+ CHECK(unnamed->array.length == 101, "with no header the header row is data");
+
+ auto empty = xl::to_arrow(wb->sheet(0), std::span{});
+ CHECK(!empty.has_value() && empty.error().code == XL_INVALID_ARGUMENT, "an empty schema is rejected");
+
+ auto stream = xl::to_arrow_stream(wb->sheet(0), *schema, {.batch_size = 8});
+ CHECK(stream.has_value(), "to_arrow_stream over an inferred schema must open");
+ int64_t rows = 0;
+ while (true)
+ {
+ auto batch = stream->next();
+ CHECK(batch.has_value(), "an inferred-schema batch must read without error");
+ if (!batch->has_value())
+ {
+ break;
+ }
+ rows += (*batch)->array.length;
+ }
+ CHECK(rows == 100, "the inferred stream delivers every data row");
+ }
+
std::printf("OK: C++ arrow test passed\n");
return 0;
}
diff --git a/cpp/tests/batch.cpp b/cpp/tests/batch.cpp
index 67e2a6f7..049d6b0d 100644
--- a/cpp/tests/batch.cpp
+++ b/cpp/tests/batch.cpp
@@ -189,6 +189,194 @@ namespace
auto after = reader->next();
check(after.has_value() && after->has_value(), "the reader keeps reading after a whole-sheet read");
}
+
+ const xl::ColumnRef *find_column(const xl::Table &table, std::string_view name)
+ {
+ for (const xl::ColumnRef &column : table.columns())
+ {
+ if (column.name == name)
+ {
+ return &column;
+ }
+ }
+ return nullptr;
+ }
+
+ void test_runtime_schema_matches_the_typed_parse(const std::vector &expected)
+ {
+ auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH);
+ check(workbook.has_value(), "open the fixture for the runtime-schema parse");
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto schema = workbook->sheet(0).infer_schema();
+ check(schema.has_value() && !schema->empty(), "infer_schema finds columns");
+ if (!schema.has_value())
+ {
+ return;
+ }
+
+ auto table = xl::parse(workbook->sheet(0), *schema);
+ check(table.has_value(), "parse over an inferred schema succeeds");
+ if (!table.has_value())
+ {
+ return;
+ }
+ check(table->size() == static_cast(expected.size()), "the runtime-schema parse reads every row");
+ check(table->columns().size() == schema->size(), "one column per inferred column");
+
+ const xl::ColumnRef *coluna1 = find_column(*table, "Coluna1");
+ const xl::ColumnRef *coluna3 = find_column(*table, "Coluna3");
+ check(coluna1 != nullptr && coluna1->type == XL_T_STRING, "Coluna1 is a string column");
+ check(coluna3 != nullptr && coluna3->type == XL_T_I64, "Coluna3 is an i64 column");
+ if (coluna1 == nullptr || coluna3 == nullptr || coluna1->type != XL_T_STRING || coluna3->type != XL_T_I64)
+ {
+ return;
+ }
+ const auto *offsets = static_cast(coluna1->values);
+ const auto *text = reinterpret_cast(coluna1->data);
+ const auto *numbers = static_cast(coluna3->values);
+ bool same = true;
+ for (size_t row = 0; row < expected.size(); ++row)
+ {
+ const std::string_view cell(text + offsets[row], static_cast(offsets[row + 1] - offsets[row]));
+ same = same && cell == expected[row].first && numbers[row] == expected[row].second;
+ }
+ check(same, "the runtime-schema parse matches the typed parse cell for cell");
+
+ std::vector bytes;
+ check(xl::write_columns(bytes, table->columns(), {.format = XL_FORMAT_XLSX}).has_value(),
+ "a runtime-schema table writes back as it was read");
+ auto reopened = xl::Workbook::open(bytes);
+ check(reopened.has_value(), "the written workbook opens");
+ if (!reopened.has_value())
+ {
+ return;
+ }
+ auto again = xl::parse(reopened->sheet(0), *schema);
+ check(again.has_value() && again->size() == table->size(), "the written workbook parses with the same schema");
+
+ auto by_index = workbook->sheet(0).infer_schema(0);
+ check(by_index.has_value() && !(*by_index)[0].name.has_value(), "a schema without a header has no names");
+ if (by_index.has_value())
+ {
+ auto unnamed = xl::parse(workbook->sheet(0), *by_index, {.header_row = 0});
+ check(unnamed.has_value() && unnamed->size() == static_cast(expected.size()) + 1,
+ "unnamed columns resolve by index, and with no header the header row is data");
+ }
+
+ auto empty = xl::parse(workbook->sheet(0), std::span{});
+ check(!empty.has_value() && empty.error().code == XL_INVALID_ARGUMENT, "an empty schema is rejected");
+
+ xl::Table moved = std::move(*table);
+ check(find_column(moved, "Coluna3") != nullptr, "a moved table keeps its column names");
+
+ auto replacement = xl::parse(workbook->sheet(0), *schema);
+ check(replacement.has_value(), "parse a second table to move-assign");
+ if (replacement.has_value())
+ {
+ moved = std::move(*replacement);
+ check(moved.size() == static_cast(expected.size()) && find_column(moved, "Coluna1") != nullptr,
+ "a move-assigned table holds the other table's rows and names");
+ }
+ }
+
+ void test_move_assigned_view_holds_the_other_rows(const std::vector &expected)
+ {
+ auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH);
+ check(workbook.has_value(), "open the fixture for the move-assigned view");
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto whole = xl::parse(workbook->sheet(0));
+ auto reader = xl::parse_batches(workbook->sheet(0), {.batch_size = 8});
+ check(whole.has_value() && reader.has_value(), "parse a view and open a reader to move-assign from");
+ if (!whole.has_value() || !reader.has_value())
+ {
+ return;
+ }
+ auto batch = reader->next();
+ check(batch.has_value() && batch->has_value(), "read a batch to move-assign over the whole view");
+ if (!batch.has_value() || !batch->has_value())
+ {
+ return;
+ }
+ *whole = std::move(**batch);
+ check(whole->size() == 8 && std::string((*whole)[0].Coluna1) == expected[0].first,
+ "a move-assigned view holds the batch's rows");
+ }
+
+ void test_a_sheet_past_the_end_fails_every_read()
+ {
+ auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH);
+ check(workbook.has_value(), "open the fixture for the past-the-end sheet");
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ const xl::Sheet missing = workbook->sheet(99);
+ const xl::InferredColumn schema[] = {{"Coluna3", 0, XL_T_I64, false}};
+ check(!xl::parse(missing).has_value(), "parse fails on a sheet past the end");
+ check(!xl::parse_batches(missing).has_value(), "parse_batches fails on a sheet past the end");
+ check(!xl::parse_batches(missing, schema).has_value(), "a runtime-schema parse_batches fails on a sheet past the end");
+ }
+
+ void test_runtime_schema_batches_equal_whole_sheet(const std::vector &expected)
+ {
+ auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH);
+ check(workbook.has_value(), "open the fixture for the runtime-schema batched read");
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto schema = workbook->sheet(0).infer_schema();
+ check(schema.has_value(), "infer_schema succeeds for the batched read");
+ if (!schema.has_value())
+ {
+ return;
+ }
+
+ auto reader = xl::parse_batches(workbook->sheet(0), *schema, {.batch_size = 8});
+ check(reader.has_value(), "parse_batches over an inferred schema opens");
+ if (!reader.has_value())
+ {
+ return;
+ }
+ schema->clear();
+
+ std::vector coluna3;
+ int batches = 0;
+ for (auto &batch : *reader)
+ {
+ check(batch.has_value(), "a runtime-schema batch reads without error");
+ if (!batch.has_value())
+ {
+ return;
+ }
+ ++batches;
+ check(batch->size() <= 8, "no batch exceeds the requested batch size");
+ const xl::ColumnRef *column = find_column(*batch, "Coluna3");
+ check(column != nullptr && column->type == XL_T_I64, "every batch keeps the schema's names and types");
+ if (column == nullptr || column->type != XL_T_I64)
+ {
+ return;
+ }
+ const auto *values = static_cast(column->values);
+ coluna3.insert(coluna3.end(), values, values + column->length);
+ }
+ check(batches == 13, "100 rows at batch size 8 is 13 batches");
+ bool same = coluna3.size() == expected.size();
+ for (size_t row = 0; same && row < expected.size(); ++row)
+ {
+ same = coluna3[row] == expected[row].second;
+ }
+ check(same, "the batches concatenate to the whole-sheet read");
+
+ auto rejected = xl::parse_batches(workbook->sheet(0), *schema, {.batch_size = 0});
+ check(!rejected.has_value() && rejected.error().code == XL_INVALID_ARGUMENT, "a batch size below 1 is rejected");
+ }
}
int main()
@@ -200,6 +388,10 @@ int main()
test_second_reader_is_allowed();
test_non_positive_batch_size_is_rejected();
test_foreign_read_leaves_the_reader_alone();
+ test_runtime_schema_matches_the_typed_parse(expected);
+ test_runtime_schema_batches_equal_whole_sheet(expected);
+ test_move_assigned_view_holds_the_other_rows(expected);
+ test_a_sheet_past_the_end_fails_every_read();
if (failures != 0)
{
diff --git a/cpp/tests/numbers.cpp b/cpp/tests/numbers.cpp
index 152e2123..1750915d 100644
--- a/cpp/tests/numbers.cpp
+++ b/cpp/tests/numbers.cpp
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
namespace
@@ -61,6 +62,8 @@ int main()
check(xl::CellView{0, xl::CellType::Number, {}, 0.0125}.text() == "0.0125", "a fraction places its point");
check(xl::CellView{0, xl::CellType::Number, {}, -2.5}.text() == "-2.5", "a negative keeps its sign");
check(xl::CellView{0, xl::CellType::Number, {}, 0.0}.text() == "0", "zero is 0");
+ check(xl::CellView{0, xl::CellType::Number, {}, std::numeric_limits::infinity()}.text() == "inf",
+ "infinity has no exponent to place");
round_trips(0.1);
round_trips(1.0 / 3.0);
round_trips(1e21);
diff --git a/cpp/tests/sheets.cpp b/cpp/tests/sheets.cpp
index 9942daef..f4d682f7 100644
--- a/cpp/tests/sheets.cpp
+++ b/cpp/tests/sheets.cpp
@@ -113,6 +113,25 @@ int main()
check(decoded.has_value() && decoded->size() == 3, "read_all reads the whole named sheet");
auto schema = workbook->sheet(1).infer_schema(0, 10);
check(schema.has_value() && schema->size() == 1, "infer_schema samples the named sheet");
+
+ const xl::Sheet beyond = workbook->sheet(2);
+ check(!beyond.name().has_value(), "a sheet past the end has no name");
+ check(!beyond.visibility().has_value(), "a sheet past the end has no visibility");
+ check(!beyond.read_all().has_value(), "read_all fails on a sheet past the end");
+ check(!beyond.infer_schema().has_value(), "infer_schema fails on a sheet past the end");
+ }
+ }
+
+ {
+ auto workbook = xl::Workbook::open(two);
+ check(workbook.has_value(), "open for the moved-from workbook");
+ if (workbook.has_value())
+ {
+ xl::Workbook moved = std::move(*workbook);
+ check(!workbook->sheets().has_value(), "a moved-from workbook lists no sheets");
+ check(!workbook->sheet_by_name("sheet0").has_value(), "a moved-from workbook finds no sheet by name");
+ check(!workbook->is_date1904().has_value(), "a moved-from workbook has no date system");
+ check(moved.is_date1904().has_value(), "the moved-to workbook still answers");
}
}
diff --git a/cpp/tests/sources.cpp b/cpp/tests/sources.cpp
index 76c265d9..11024e1b 100644
--- a/cpp/tests/sources.cpp
+++ b/cpp/tests/sources.cpp
@@ -1,4 +1,5 @@
#include
+#include
#include
#include
@@ -7,12 +8,25 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
#include
+struct Quantity
+{
+ int64_t qty;
+};
+
+template <>
+struct xl::ExcelMapper
+{
+ static constexpr auto get_bindings() { return std::make_tuple(xl::make_field("qty", &Quantity::qty)); }
+};
+
namespace
{
int failures = 0;
@@ -98,6 +112,50 @@ namespace
Counters &counters_;
};
+ class OpaqueThrowingSource final : public xl::Source
+ {
+ public:
+ std::uint64_t size() const override { return 1024; }
+ std::expected read_at(std::uint64_t, std::span) const override
+ {
+ throw 42;
+ }
+ };
+
+ class HugeSource final : public xl::Source
+ {
+ public:
+ std::uint64_t size() const override { return std::numeric_limits::max(); }
+ std::expected read_at(std::uint64_t, std::span) const override
+ {
+ return std::size_t{0};
+ }
+ };
+
+ /// Serves the first `trip` bytes and fails every read past them, so a read fails partway.
+ class TrippingSource final : public xl::Source
+ {
+ public:
+ TrippingSource(std::vector bytes, std::uint64_t trip) : bytes_(std::move(bytes)), trip_(trip) {}
+
+ std::uint64_t size() const override { return bytes_.size(); }
+
+ std::expected read_at(std::uint64_t offset, std::span buffer) const override
+ {
+ if (offset >= trip_)
+ {
+ return std::unexpected(std::string("disk failed mid-read"));
+ }
+ const std::size_t count = std::min(buffer.size(), bytes_.size() - offset);
+ std::memcpy(buffer.data(), bytes_.data() + offset, count);
+ return count;
+ }
+
+ private:
+ std::vector bytes_;
+ std::uint64_t trip_;
+ };
+
class ErrorStream final : public xl::InputStream
{
public:
@@ -172,6 +230,108 @@ namespace
auto decoded = workbook.sheet(0).read_all();
return decoded.has_value() ? decoded->size() : 0;
}
+
+ bool mentions(const xl::Error &error, std::string_view text)
+ {
+ return error.message.find(text) != std::string::npos;
+ }
+
+ std::expected open_tripping()
+ {
+ std::string csv = "qty\n";
+ for (int i = 0; i < 50'000; ++i)
+ {
+ csv += std::to_string(i) + "\n";
+ }
+ std::vector bytes(csv.size());
+ std::memcpy(bytes.data(), csv.data(), csv.size());
+ const std::uint64_t trip = bytes.size() / 2;
+ return xl::Workbook::open(std::make_unique(std::move(bytes), trip),
+ {.format = XL_FORMAT_CSV, .source_block_size = -1});
+ }
+
+ /// Drains `next()` and returns the error it stopped on, or nothing if the sheet ended cleanly.
+ template
+ std::optional first_error(Reader &reader)
+ {
+ while (true)
+ {
+ auto batch = reader.next();
+ if (!batch.has_value())
+ {
+ return batch.error();
+ }
+ if (!batch->has_value())
+ {
+ return std::nullopt;
+ }
+ }
+ }
+
+ const xl::InferredColumn kQtySchema[] = {{"qty", 0, XL_T_I64, false}};
+
+ void test_a_typed_range_ends_on_a_failed_read()
+ {
+ auto workbook = open_tripping();
+ check(workbook.has_value(), "a tripping source opens");
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto reader = xl::parse_batches(workbook->sheet(0), {.batch_size = 100});
+ check(reader.has_value(), "a typed reader opens before the failure");
+ if (!reader.has_value())
+ {
+ return;
+ }
+ int64_t rows = 0;
+ std::optional last;
+ for (auto &batch : *reader)
+ {
+ if (!batch.has_value())
+ {
+ last = batch.error();
+ break;
+ }
+ rows += batch->size();
+ }
+ check(rows > 0, "batches before the failure arrive");
+ check(last.has_value() && mentions(*last, "disk failed mid-read"), "the range ends on the source's error");
+ }
+
+ void test_a_table_reader_reports_a_failed_read()
+ {
+ auto workbook = open_tripping();
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto reader = xl::parse_batches(workbook->sheet(0), kQtySchema, {.batch_size = 100});
+ check(reader.has_value(), "a table reader opens before the failure");
+ if (!reader.has_value())
+ {
+ return;
+ }
+ const std::optional error = first_error(*reader);
+ check(error.has_value() && mentions(*error, "disk failed mid-read"), "a table reader reports the source's error");
+ }
+
+ void test_an_arrow_stream_reports_a_failed_read()
+ {
+ auto workbook = open_tripping();
+ if (!workbook.has_value())
+ {
+ return;
+ }
+ auto stream = xl::to_arrow_stream(workbook->sheet(0), kQtySchema, {.batch_size = 100});
+ check(stream.has_value(), "an Arrow stream opens before the failure");
+ if (!stream.has_value())
+ {
+ return;
+ }
+ const std::optional error = first_error(*stream);
+ check(error.has_value() && mentions(*error, "disk failed mid-read"), "an Arrow stream reports the source's error");
+ }
}
int main()
@@ -215,6 +375,22 @@ int main()
"an exception from the source becomes an error");
check(throwing.destroyed.load() == 1, "a source that threw is destroyed exactly once");
}
+ {
+ auto workbook = xl::Workbook::open(std::make_unique(), {.format = XL_FORMAT_XLSX});
+ check(!workbook.has_value() && mentions(workbook.error(), "the source threw an exception"),
+ "a throw that is not a std::exception becomes an error");
+ }
+ {
+ auto null_source = xl::Workbook::open(std::unique_ptr{});
+ check(!null_source.has_value() && null_source.error().code == XL_INVALID_ARGUMENT, "a null source is rejected");
+ auto null_stream = xl::Workbook::open(std::unique_ptr{});
+ check(!null_stream.has_value() && null_stream.error().code == XL_INVALID_ARGUMENT, "a null stream is rejected");
+ auto huge = xl::Workbook::open(std::make_unique());
+ check(!huge.has_value() && huge.error().code == XL_INVALID_ARGUMENT, "a source larger than INT64_MAX is rejected");
+ }
+ test_a_typed_range_ends_on_a_failed_read();
+ test_a_table_reader_reports_a_failed_read();
+ test_an_arrow_stream_reports_a_failed_read();
{
auto workbook = xl::Workbook::open(std::make_unique(), {.format = XL_FORMAT_XLSX});
check(!workbook.has_value() && workbook.error().message.find("pipe broke") != std::string::npos,
diff --git a/cpp/tests/write.cpp b/cpp/tests/write.cpp
index b1d8d0b9..3db2cf95 100644
--- a/cpp/tests/write.cpp
+++ b/cpp/tests/write.cpp
@@ -45,6 +45,43 @@ struct xl::ExcelMapper
}
};
+struct NarrowRow
+{
+ uint8_t small;
+};
+
+template <>
+struct xl::ExcelMapper
+{
+ static constexpr auto get_bindings() { return std::make_tuple(xl::make_field("small", &NarrowRow::small)); }
+};
+
+struct WideRow
+{
+ uint64_t big;
+};
+
+template <>
+struct xl::ExcelMapper
+{
+ static constexpr auto get_bindings() { return std::make_tuple(xl::make_field("big", &WideRow::big)); }
+};
+
+struct NamedRow
+{
+ std::string name = "fallback";
+ std::string_view view = "fallback";
+};
+
+template <>
+struct xl::ExcelMapper
+{
+ static constexpr auto get_bindings()
+ {
+ return std::make_tuple(xl::make_field("name", &NamedRow::name), xl::make_field("view", &NamedRow::view));
+ }
+};
+
static std::filesystem::path temp_path(std::string_view name)
{
return std::filesystem::temp_directory_path() /
@@ -302,6 +339,7 @@ static int test_format_from_extension()
}
CHECK(!xl::write_columns(temp_path("no-extension").string(), columns).has_value(),
"a path without an extension must be rejected");
+ CHECK(!xl::write_columns("ab", columns).has_value(), "a name shorter than any extension must be rejected");
return 0;
}
@@ -711,8 +749,102 @@ static int test_writer_handle_class_rejects_auto_for_memory()
return 0;
}
+static std::expected, xl::Error> parse_narrow(const std::vector &values,
+ std::vector &bytes)
+{
+ std::array columns{xl::i64_column("small", values)};
+ if (!xl::write_columns(bytes, columns, {.format = XL_FORMAT_XLSX}).has_value())
+ {
+ return std::unexpected(xl::Error{XL_ERROR, "write_columns failed"});
+ }
+ auto workbook = xl::Workbook::open(std::span(bytes));
+ if (!workbook.has_value())
+ {
+ return std::unexpected(workbook.error());
+ }
+ auto sheets = workbook->sheets();
+ if (!sheets.has_value())
+ {
+ return std::unexpected(sheets.error());
+ }
+ return xl::parse(sheets->front());
+}
+
+static int test_narrow_field_rejects_a_value_it_cannot_hold()
+{
+ std::vector bytes;
+ auto too_big = parse_narrow({1, 300}, bytes);
+ CHECK(!too_big.has_value(), "300 must not be read into a uint8_t");
+ CHECK(too_big.error().code == XL_INVALID_ARGUMENT, "an out-of-range value is an invalid argument");
+ CHECK(too_big.error().message.find("small") != std::string::npos, "the error must name the column");
+
+ auto negative = parse_narrow({-1}, bytes);
+ CHECK(!negative.has_value(), "-1 must not be read into a uint8_t");
+
+ auto limits = parse_narrow({0, 255}, bytes);
+ CHECK(limits.has_value(), "values at the uint8_t limits must parse");
+ CHECK((*limits)[0].small == 0 && (*limits)[1].small == 255, "values at the limits must round-trip");
+
+ (void)parse_narrow({300}, bytes);
+ auto workbook = xl::Workbook::open(std::span(bytes));
+ CHECK(workbook.has_value(), "the written bytes must open");
+ auto batches = xl::parse_batches(workbook->sheets()->front());
+ CHECK(batches.has_value(), "parse_batches must open");
+ auto batch = batches->next();
+ CHECK(!batch.has_value(), "a batch holding 300 must fail for a uint8_t field");
+ return 0;
+}
+
+static int test_unsigned_values_past_int64_are_refused_on_write()
+{
+ std::vector bytes;
+ const std::vector rows{{static_cast(INT64_MAX) + 1}};
+ auto written = xl::write_sheet(bytes, rows, {.format = XL_FORMAT_XLSX});
+ CHECK(!written.has_value(), "a uint64_t above INT64_MAX must not be written as a negative number");
+
+ const std::vector limit{{static_cast(INT64_MAX)}};
+ CHECK(xl::write_sheet(bytes, limit, {.format = XL_FORMAT_XLSX}).has_value(), "INT64_MAX must be written");
+ auto workbook = xl::Workbook::open(std::span(bytes));
+ CHECK(workbook.has_value(), "the written bytes must open");
+ auto table = xl::parse(workbook->sheets()->front());
+ CHECK(table.has_value() && (*table)[0].big == static_cast(INT64_MAX), "INT64_MAX must round-trip");
+
+ std::vector streamed;
+ auto handle = xl::WriterHandle::open(streamed, {.format = XL_FORMAT_XLSX});
+ CHECK(handle.has_value(), "the writer handle must open");
+ CHECK(handle->start_sheet("S").has_value() && handle->start_row().has_value(), "the sheet and row must start");
+ CHECK(!handle->write(UINT64_MAX).has_value(), "WriterHandle::write must refuse a uint64_t above INT64_MAX");
+ return 0;
+}
+
+static int test_a_valid_empty_string_replaces_the_default()
+{
+ const int32_t offsets[] = {0, 0};
+ xl_column columns[] = {
+ xl_column{XL_T_STRING, 1, offsets, nullptr, nullptr, 0},
+ xl_column{XL_T_STRING, 1, offsets, nullptr, nullptr, 0},
+ };
+ const xl_table table{2, 1, columns};
+ const NamedRow row = xl::detail::row_at(table, 0);
+ CHECK(row.name.empty(), "an empty std::string must overwrite the default");
+ CHECK(row.view.empty(), "an empty std::string_view must overwrite the default");
+ return 0;
+}
+
int main()
{
+ if (test_narrow_field_rejects_a_value_it_cannot_hold() != 0)
+ {
+ return 1;
+ }
+ if (test_unsigned_values_past_int64_are_refused_on_write() != 0)
+ {
+ return 1;
+ }
+ if (test_a_valid_empty_string_replaces_the_default() != 0)
+ {
+ return 1;
+ }
if (test_write_options() != 0)
{
return 1;
diff --git a/docs/guide/reading.md b/docs/guide/reading.md
index c02d2186..4a9b038c 100644
--- a/docs/guide/reading.md
+++ b/docs/guide/reading.md
@@ -121,11 +121,12 @@ These cases read on one thread with the same results and no error:
- **`ParseAsync` and `AggregateAsync`** are exact on every format. `ParseAsync` yields the same items in the same order as the sequential parse; `AggregateAsync` equals its own single-threaded run.
- **XLSB** delivers every row exactly once, in `ForEachAsync` too.
- **XLSX** delivers exactly once for a well-formed sheet. The pipeline cuts the inflated sheet at what looks like a row start; on a sheet where those bytes sit inside cell content (a CDATA run, a comment) or the file is truncated, a worker detects it, and the read recovers by reading the rest sequentially. `ParseAsync` and `AggregateAsync` stay exact. In `ForEachAsync`, workers may already have called the callback for chunks that are then dropped, so on such a sheet rows can be delivered twice, or be rows the sequential reader would not produce.
-- **CSV** is at least once in `ForEachAsync`, unchanged from v6: the callback may be invoked more than once for the same record. A partition whose start offset was guessed wrongly is read again from the confirmed offset, and the discarded pass may already have delivered a whole partition's worth of records, 1 MiB to 64 MiB of them, not just a few near the seam. On a source with quoted fields those records may also be misparsed, carrying values that appear nowhere in the file, and an exception the callback threw during a discarded pass is discarded with it. A start is only guessed wrongly inside a quoted field, so a source in which the quote character never appears delivers every record exactly once. For that guarantee on any source, use `AggregateAsync`, where the discarded partition's accumulator is thrown away, and apply side effects once it returns.
+- **CSV** delivers exactly once when it follows RFC 4180 quoting (every quote opens or closes a quoted field, or is doubled inside one) and no quoted field is longer than 64 KB. A partition's start is read off the quotes around it, and a partition whose quotes could open as well as close a field waits for its confirmed start instead of being guessed.
+- **CSV outside those rules** (a stray quote inside an unquoted field, text after a closing quote, a quoted field past 64 KB) is at least once in `ForEachAsync`: the callback may be invoked more than once for the same record. A partition whose start offset was guessed wrongly is read again from the confirmed offset, and the discarded pass may already have delivered a whole partition's worth of records, 1 MiB to 64 MiB of them, not just a few near the seam. Those records may also be misparsed, carrying values that appear nowhere in the file, and an exception the callback threw during a discarded pass is discarded with it. For exactly once on any source, use `AggregateAsync`, where the discarded partition's accumulator is thrown away, and apply side effects once it returns.
-A callback that writes to a database or sends messages must be idempotent on CSV, and on XLSX if the sheet may be malformed, and must also tolerate records that do not exist: deduplicating on a key does not filter those out.
+A callback that writes to a database or sends messages must be idempotent on a CSV that may break those rules, and on XLSX if the sheet may be malformed, and must also tolerate records that do not exist: deduplicating on a key does not filter those out.
-[`ExcelParseException.Row`](xref:ExcelReader.Core.Parser.ExcelParseException.Row) on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the exact row when the read is sequential and `0` when it was partitioned.
+[`ExcelParseException.Row`](xref:ExcelReader.Core.Parser.ExcelParseException.Row) on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the row a sequential read reports, whether or not the read was partitioned.
See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 28 ms (prefetched, sequential) to about 17 ms, and on XLSB from 17 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 4 is ~1.8x faster than the typed path while allocating ~450x less, with zero garbage collections.
diff --git a/python/src/excelreader/reader.py b/python/src/excelreader/reader.py
index bb07f645..161f2602 100644
--- a/python/src/excelreader/reader.py
+++ b/python/src/excelreader/reader.py
@@ -249,7 +249,7 @@ def rows(self) -> Iterator[list[Cell]]:
buffer = ctypes.create_string_buffer(capacity)
continue
_check(status)
- yield _decode_row(buffer.raw, written.value, date1904)
+ yield _decode_row(buffer[: written.value], written.value, date1904)
finally:
self._close_rows(cursor)
diff --git a/python/src/excelreader/writer.py b/python/src/excelreader/writer.py
index 57d27c60..32324c3e 100644
--- a/python/src/excelreader/writer.py
+++ b/python/src/excelreader/writer.py
@@ -41,54 +41,81 @@ def _resolve_write_format(name: str | None, path: Path) -> int:
) from None
-def _buffer_of(values: Any) -> tuple[Any, int]:
- """Returns (ctypes-addressable buffer, byte length) for one column's values.
-
- NumPy arrays, `array.array` and `bytes` all expose the buffer protocol, so one `memoryview` reads
- every input shape the same way. The bytes are then COPIED into a ctypes block rather than
- borrowed: the copy is owned by this module and cannot be moved or freed out from under the native
- side while the call runs. The caller must keep the returned object alive for as long as the
- pointer is in use.
+_SIGNED_FORMATS = frozenset("bhilqn")
+_ELEMENTS = {
+ ColumnType.I64: (_SIGNED_FORMATS, 8),
+ ColumnType.TIME: (_SIGNED_FORMATS, 8),
+ ColumnType.TIMESTAMP: (_SIGNED_FORMATS, 8),
+ ColumnType.F64: (frozenset("d"), 8),
+ ColumnType.BOOL: (frozenset("bB?"), 1),
+ ColumnType.DATE: (_SIGNED_FORMATS, 4),
+}
+
+
+def _checked_view(values: Any, formats: frozenset[str], itemsize: int, count: int, what: str) -> memoryview:
+ """The native side trusts `length` and the column type, and cannot see how big a Python
+ allocation really is, so a buffer of the wrong shape must be refused here, before any pointer
+ to it exists.
"""
- if isinstance(values, (bytes, bytearray)):
- raw: Any = values
- elif isinstance(values, array):
- raw = values
- else:
- raw = getattr(values, "data", values)
- blob = memoryview(raw).tobytes()
- return (ctypes.c_char * len(blob)).from_buffer_copy(blob), len(blob)
+ view = memoryview(getattr(values, "data", values))
+ code = view.format.lstrip("@=")
+ if view.ndim != 1 or view.itemsize != itemsize or code not in formats:
+ raise TypeError(
+ f"{what} must be a 1-D buffer of {itemsize}-byte elements with format one of {sorted(formats)}; "
+ f"got format {view.format!r} with {view.itemsize}-byte elements"
+ )
+ if len(view) != count:
+ raise ValueError(f"{what} has {len(view)} elements but {count} are needed")
+ return view
+
+
+def _copy(view: memoryview) -> Any:
+ """COPIES the bytes into a ctypes block rather than borrowing them: the copy is owned by this
+ module and cannot be moved or freed out from under the native side while the call runs. The
+ caller must keep the returned object alive for as long as the pointer is in use.
+ """
+ blob = view.tobytes()
+ return (ctypes.c_char * len(blob)).from_buffer_copy(blob)
def _native_column(column: Any, column_type: ColumnType, validity: bytes | None, row_count: int, keepalive: list[Any]) -> _native.NativeColumn:
if isinstance(column, StringColumn):
- offsets, _ = _buffer_of(column.offsets)
- data, data_len = _buffer_of(column.data)
+ offsets = _copy(_checked_view(column.offsets, _SIGNED_FORMATS, 4, row_count + 1, "a STRING column's offsets"))
+ data_view = memoryview(column.data)
+ data_len = data_view.nbytes
+ data = _copy(data_view)
keepalive += [offsets, data]
return _native.NativeColumn(
type=int(ColumnType.STRING),
length=row_count,
values=ctypes.cast(offsets, ctypes.c_void_p),
- validity=_validity_pointer(validity, keepalive),
+ validity=_validity_pointer(validity, row_count, keepalive),
data=ctypes.cast(data, ctypes.POINTER(ctypes.c_uint8)) if data_len else None,
data_len=data_len,
)
- values, _ = _buffer_of(column)
+ if column_type is ColumnType.STRING:
+ raise TypeError("a STRING column must be a StringColumn")
+ formats, itemsize = _ELEMENTS[column_type]
+ values = _copy(_checked_view(column, formats, itemsize, row_count, f"a {column_type.name} column"))
keepalive.append(values)
return _native.NativeColumn(
type=int(column_type),
length=row_count,
values=ctypes.cast(values, ctypes.c_void_p),
- validity=_validity_pointer(validity, keepalive),
+ validity=_validity_pointer(validity, row_count, keepalive),
data=None,
data_len=0,
)
-def _validity_pointer(validity: bytes | None, keepalive: list[Any]) -> Any:
+def _validity_pointer(validity: bytes | None, row_count: int, keepalive: list[Any]) -> Any:
if validity is None:
return None
+ if len(validity) < (row_count + 7) // 8:
+ raise ValueError(
+ f"the validity bitmap is {len(validity)} bytes, but {row_count} rows need {(row_count + 7) // 8}"
+ )
buffer = (ctypes.c_uint8 * len(validity)).from_buffer_copy(validity)
keepalive.append(buffer)
return ctypes.cast(buffer, ctypes.POINTER(ctypes.c_uint8))
@@ -150,7 +177,8 @@ def write_workbook(
`format` is one of xlsx/xlsb/xls/csv. For a path, None infers it from the extension; an object
with `write()` has no extension, so it needs `format`. The whole workbook is built in memory and
- handed to `write()` in one call, at the object's current position; the object is not closed.
+ handed to `write()` at the object's current position, again for the rest whenever `write()`
+ returns that it took only part; the object is not closed.
The column names become the header row. `options` sets the sheet name and CSV dialect; see
`WriteOptions`.
@@ -174,7 +202,24 @@ def write_workbook(
raise ValueError(
f"format= is required when writing to an object with write(); one of {sorted(_FORMAT_NAMES)}"
)
- destination.write(_write_memory(table, types, format, options))
+ _write_all(destination, _write_memory(table, types, format, options))
+
+
+def _write_all(destination: IO[bytes], data: bytes) -> None:
+ """Calls `write()` until it has taken all of `data`. A `write()` that returns None is taken to
+ have written everything, as files that predate the `int` return convention do.
+ """
+ view = memoryview(data)
+ offset = 0
+ while offset < len(data):
+ written = destination.write(data if offset == 0 else view[offset:])
+ if written is None:
+ return
+ if not 0 < written <= len(data) - offset:
+ raise OSError(
+ f"write() made no progress: it returned {written} with {len(data) - offset} bytes left to write"
+ )
+ offset += written
def _write_file(
@@ -371,7 +416,7 @@ def encrypt_package(
`package` is a path to a complete file, or its bytes (`bytes`, `bytearray`, `memoryview`, or an
`io.BytesIO` read whole). `destination` is a path, or a binary object with `write()` that
- receives the encrypted workbook in one call and is not closed. Encryption parameters are fixed
+ receives the encrypted workbook, as `write_workbook` hands it over, and is not closed. Encryption parameters are fixed
at Excel's own defaults; there are no options.
"""
password_encoded = password.encode("utf-8")
@@ -414,4 +459,4 @@ def encrypt_package(
if isinstance(destination, (str, os.PathLike)):
Path(destination).write_bytes(encrypted)
else:
- destination.write(encrypted)
+ _write_all(destination, encrypted)
diff --git a/python/src/excelreader/writer_handle.py b/python/src/excelreader/writer_handle.py
index 0356d13b..1891f765 100644
--- a/python/src/excelreader/writer_handle.py
+++ b/python/src/excelreader/writer_handle.py
@@ -16,6 +16,7 @@
from excelreader import _native
from excelreader.reader import _check
from excelreader.types import ColumnType, WriteOptions
+from excelreader.writer import _write_all
_EPOCH_DATE = datetime.date(1970, 1, 1)
_EPOCH_DATETIME = datetime.datetime(1970, 1, 1)
@@ -131,8 +132,8 @@ def _finished_bytes(self, handle: ctypes.c_void_p) -> bytes:
def close(self) -> None:
"""Finalizes and releases the writer. Safe to call more than once.
- For a destination with `write()`, the finished workbook is written to it in one call, even
- when sheets or rows were left open.
+ For a destination with `write()`, the finished workbook is written to it, even when sheets
+ or rows were left open; a `write()` that takes only part of it is called again for the rest.
"""
if self._handle is None:
return
@@ -140,7 +141,7 @@ def close(self) -> None:
destination, self._destination = self._destination, None
try:
if destination is not None:
- destination.write(self._finished_bytes(handle))
+ _write_all(destination, self._finished_bytes(handle))
finally:
_check(self._lib.xl_close_write_handle(handle))
diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py
index e5f0be9a..b286b8fe 100644
--- a/python/tests/test_reader.py
+++ b/python/tests/test_reader.py
@@ -361,6 +361,16 @@ def test_open_options_rejects_an_out_of_range_value(csv_path):
open_workbook(csv_path, format="csv", options=OpenOptions(csv_delimiter=999))
+def test_rows_after_one_that_grew_the_buffer_decode_only_their_own_bytes(tmp_path):
+ path = tmp_path / "grown.csv"
+ path.write_text("value\n" + ("x" * 200_000) + "\nação\n\n7\n", encoding="utf-8")
+
+ with open_workbook(path, format="csv") as workbook:
+ rows = [[cell.value for cell in row] for row in workbook.sheets[0].rows()]
+
+ assert rows[2:] == [["ação"], [""], ["7"]]
+
+
def test_open_options_limit_actually_aborts_an_oversized_read(tmp_path):
path = tmp_path / "wide-cell.csv"
path.write_text("value\n" + ("x" * 200_000) + "\n", encoding="utf-8")
diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py
index 8c10c697..2e226bd8 100644
--- a/python/tests/test_writer.py
+++ b/python/tests/test_writer.py
@@ -1,4 +1,5 @@
import io
+from array import array
import pytest
from excelreader import (
@@ -6,6 +7,8 @@
ColumnType,
ExcelReaderError,
PasswordIncorrectError,
+ StringColumn,
+ TypedTable,
WriteOptions,
encrypt_package,
open_workbook,
@@ -390,3 +393,97 @@ def test_write_workbook_rejects_a_destination_it_cannot_write(source_csv):
with pytest.raises(TypeError, match="destination"):
write_workbook(42, table, _TYPES, format="xlsx")
+
+
+class _TrickleWriter:
+ def __init__(self, step):
+ self.step = step
+ self.data = bytearray()
+
+ def write(self, chunk):
+ taken = bytes(chunk[: self.step])
+ self.data += taken
+ return len(taken)
+
+
+def test_write_workbook_finishes_a_partial_write(source_csv):
+ with open_workbook(source_csv) as workbook:
+ table = workbook.sheets[0].parse(_SCHEMA)
+ destination = _TrickleWriter(4)
+
+ write_workbook(destination, table, _TYPES, format="xlsx")
+
+ assert bytes(destination.data) == _to_bytes(table)
+
+
+def test_write_workbook_fails_when_write_makes_no_progress(source_csv):
+ with open_workbook(source_csv) as workbook:
+ table = workbook.sheets[0].parse(_SCHEMA)
+
+ with pytest.raises(OSError, match="no progress"):
+ write_workbook(_TrickleWriter(0), table, _TYPES, format="xlsx")
+
+
+def test_encrypt_package_finishes_a_partial_write(source_csv):
+ with open_workbook(source_csv) as workbook:
+ table = workbook.sheets[0].parse(_SCHEMA)
+ destination = _TrickleWriter(7)
+
+ encrypt_package(_to_bytes(table), destination, "hunter2")
+
+ result = _parse_bytes(bytes(destination.data), "xlsx", password="hunter2")
+ assert list(result.columns[0]) == ["widget", "gadget"]
+
+
+def _one_column_table(column, rows=1, validity=None):
+ return TypedTable(row_count=rows, names=["n"], columns=[column], validity=[validity])
+
+
+@pytest.mark.parametrize(
+ ("column", "column_type", "rows", "message"),
+ [
+ (array("q", [7]), ColumnType.I64, 10, "1 elements but 10"),
+ (array("i", [1, 2]), ColumnType.I64, 1, "8-byte"),
+ (array("d", [1.0]), ColumnType.I64, 1, "format"),
+ (array("Q", [1]), ColumnType.I64, 1, "format"),
+ (array("q", [1]), ColumnType.DATE, 1, "4-byte"),
+ (StringColumn(array("i", [0]), b""), ColumnType.STRING, 1, "offsets"),
+ (StringColumn(array("q", [0, 1]), b"a"), ColumnType.STRING, 1, "offsets"),
+ ],
+)
+def test_write_workbook_rejects_a_column_buffer_that_does_not_match(column, column_type, rows, message):
+ with pytest.raises((TypeError, ValueError), match=message):
+ write_workbook(io.BytesIO(), _one_column_table(column, rows), [column_type], format="xlsx")
+
+
+def test_write_workbook_rejects_a_short_validity_bitmap():
+ table = _one_column_table(array("q", range(9)), rows=9, validity=b"\xff")
+
+ with pytest.raises(ValueError, match="validity"):
+ write_workbook(io.BytesIO(), table, [ColumnType.I64], format="xlsx")
+
+
+def test_write_workbook_takes_numpy_columns():
+ numpy = pytest.importorskip("numpy")
+ table = TypedTable(
+ row_count=2,
+ names=["qty", "price", "day", "ok"],
+ columns=[
+ numpy.array([3, 7], dtype=numpy.int64),
+ numpy.array([1.5, 2.5]),
+ numpy.array([0, 1], dtype=numpy.int32),
+ numpy.array([True, False]),
+ ],
+ validity=[None, None, None, None],
+ )
+ types = [ColumnType.I64, ColumnType.F64, ColumnType.DATE, ColumnType.BOOL]
+ buffer = io.BytesIO()
+
+ write_workbook(buffer, table, types, format="xlsx")
+
+ schema = [ColumnSpec(t, name=n) for n, t in zip(table.names, types)]
+ with open_workbook(buffer.getvalue(), format="xlsx") as workbook:
+ result = workbook.sheets[0].parse(schema)
+ assert list(result.columns[0]) == [3, 7]
+ assert list(result.columns[1]) == [1.5, 2.5]
+ assert list(result.columns[3]) == [1, 0]
diff --git a/python/tests/test_writer_handle.py b/python/tests/test_writer_handle.py
index cbb95378..4078ad99 100644
--- a/python/tests/test_writer_handle.py
+++ b/python/tests/test_writer_handle.py
@@ -210,6 +210,36 @@ def test_a_writer_over_a_bytesio_finishes_a_sheet_left_open():
assert [row[0].value for row in workbook.sheets[0].rows()] == ["a"]
+def test_a_writer_over_a_stream_finishes_a_partial_write():
+ class Trickle:
+ def __init__(self):
+ self.data = bytearray()
+
+ def write(self, chunk):
+ self.data += bytes(chunk[:5])
+ return min(5, len(chunk))
+
+ destination = Trickle()
+ with open_writer(destination, "xlsx") as writer:
+ writer.start_sheet("Data")
+ writer.write_row(["widget", 7])
+
+ with open_workbook(bytes(destination.data)) as workbook:
+ assert [cell.value for cell in next(workbook.sheets[0].rows())] == ["widget", "7"]
+
+
+def test_a_writer_over_a_stream_that_stalls_raises_and_still_closes():
+ class Stalled:
+ def write(self, chunk):
+ return 0
+
+ writer = open_writer(Stalled(), "xlsx")
+ writer.start_sheet("Data")
+ with pytest.raises(OSError, match="no progress"):
+ writer.close()
+ writer.close()
+
+
def test_a_writer_over_a_stream_needs_a_format_and_a_write_method():
import io
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 926a59f3..559f84c3 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -307,7 +307,7 @@ dependencies = [
"log",
"quick-xml",
"serde",
- "zip",
+ "zip 8.6.0",
]
[[package]]
@@ -318,9 +318,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
-version = "1.4.4"
+version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -332,6 +332,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
[[package]]
name = "chrono"
version = "0.4.45"
@@ -553,7 +559,7 @@ version = "0.0.0"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.3",
+ "syn 3.0.9",
]
[[package]]
@@ -564,9 +570,9 @@ checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f"
[[package]]
name = "find-msvc-tools"
-version = "0.1.11"
+version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "flate2"
@@ -800,6 +806,18 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "num-bigint"
version = "0.5.1"
@@ -979,11 +997,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rust_xlsxwriter"
-version = "0.99.1"
+version = "0.100.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34f2d2cda7223168a14fb86bc7411dbc3379bd7d49749ecc53b91ed98585b6b0"
+checksum = "c44904ffb6c57b148ffcc8ce3011c28bc49b97e1a8343b2d632283cd566e1393"
dependencies = [
- "zip",
+ "zip 9.0.5",
]
[[package]]
@@ -1034,7 +1052,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.3",
+ "syn 3.0.9",
]
[[package]]
@@ -1090,9 +1108,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.3"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -1100,10 +1118,10 @@ dependencies = [
]
[[package]]
-name = "target-triple"
-version = "1.0.1"
+name = "target-tuple"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171"
+checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f"
[[package]]
name = "termcolor"
@@ -1174,15 +1192,15 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "trybuild"
-version = "1.0.120"
+version = "1.0.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4"
+checksum = "62db9c92d704393fbf2132041720cc80b689f2d3f28521015c2ac866223c11b8"
dependencies = [
"glob",
"serde",
"serde_derive",
"serde_json",
- "target-triple",
+ "target-tuple",
"termcolor",
"toml",
]
@@ -1430,6 +1448,21 @@ dependencies = [
"zopfli",
]
+[[package]]
+name = "zip"
+version = "9.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.7"
diff --git a/rust/excelreader/Cargo.toml b/rust/excelreader/Cargo.toml
index f8c8b5fb..bf0e9942 100644
--- a/rust/excelreader/Cargo.toml
+++ b/rust/excelreader/Cargo.toml
@@ -26,7 +26,7 @@ cc = "1"
trybuild = "1"
criterion = "0.8"
calamine = { version = "0.36.1", features = ["chrono"] }
-rust_xlsxwriter = "0.99.1"
+rust_xlsxwriter = "0.100.0"
[[bench]]
name = "parse_bench"
diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md
index 7d88134d..4dc6833c 100644
--- a/rust/excelreader/README.md
+++ b/rust/excelreader/README.md
@@ -103,6 +103,34 @@ for column in sheet.infer_schema(1, 100, false)? {
`infer_schema`'s last argument, `parse_text`, also types text cells (every CSV field, or numbers
stored as text): integers, decimals, `true`/`false` and ISO dates.
+With no row type, `parse_with_schema` reads the sheet through that schema into a `Table`. Its
+columns are the `Column`s `write_columns` takes, so the table writes back as it was read:
+
+```rust,no_run
+use excelreader::{write_columns, ColumnData, ReadOptions, Workbook, WriteOptions};
+
+let workbook = Workbook::open("book.xlsx")?;
+let sheet = workbook.sheet(0)?;
+let schema = sheet.infer_schema(1, 100, false)?;
+let table = sheet.parse_with_schema(&schema, &ReadOptions::new())?;
+for column in table.columns() {
+ if let ColumnData::I64(values) = column.data {
+ println!("{:?}: {} integers", column.name, values.len());
+ }
+}
+write_columns("copy.xlsx", &table.columns(), &WriteOptions::new())?;
+
+// Or a batch at a time: TableBatches yields one Table per batch.
+for batch in sheet.parse_batches_with_schema(&schema, &ReadOptions::new())? {
+ println!("{} rows", batch?.len());
+}
+# Ok::<(), excelreader::Error>(())
+```
+
+A column matched by index has no name, and writing needs every column named or none. The schema is
+a guess over a sample: a column inferred as `I64` that holds a fraction further down fails the read,
+unless the column is `nullable`.
+
`Workbook` is `Send + Sync`, so sheets can be read in parallel over one open workbook, which
loads the shared-string table once:
@@ -249,6 +277,28 @@ Same `batch_size` semantics as `parse_batches` below. Unlike `TypedBatches`,
`ArrowBatches` does not fuse after an error — it follows arrow-rs's own semantics — so break on the
first `Err` rather than continuing the loop.
+With no row type, `to_arrow_with_schema` and `to_arrow_stream_with_schema` take the columns at run
+time instead, such as the ones `infer_schema` returned. A column with a name is matched by it, one
+without by its index:
+
+```rust,no_run
+# #[cfg(feature = "arrow")]
+# fn main() -> Result<(), excelreader::Error> {
+use excelreader::{Workbook, ReadOptions};
+
+let workbook = Workbook::open("book.xlsx")?;
+let sheet = workbook.sheet(0)?;
+let schema = sheet.infer_schema(1, 100, false)?;
+let batch = sheet.to_arrow_with_schema(&schema, &ReadOptions::new())?;
+# Ok(())
+# }
+# #[cfg(not(feature = "arrow"))]
+# fn main() {}
+```
+
+The schema is a guess over a sample: a column inferred as `I64` that holds a fraction further down
+fails the read, unless the column is `nullable`.
+
## Writing
`#[derive(ExcelMapper)]` generates both halves, so the same struct reads and writes - the field
diff --git a/rust/excelreader/src/arrow.rs b/rust/excelreader/src/arrow.rs
index cbf54060..c1c9b668 100644
--- a/rust/excelreader/src/arrow.rs
+++ b/rust/excelreader/src/arrow.rs
@@ -13,16 +13,43 @@ use arrow::ffi::{from_ffi, FFI_ArrowArray, FFI_ArrowSchema};
use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream};
use arrow::record_batch::RecordBatchReader;
-use crate::ffi;
-use crate::workbook::{build_specs, check};
-use crate::{Error, ErrorKind, ExcelMapper, ReadOptions, Sheet, Workbook};
+use crate::ffi::{self, XlColumnSpec};
+use crate::workbook::{build_specs, check, schema_specs};
+use crate::{Error, ErrorKind, ExcelMapper, InferredColumn, ReadOptions, Sheet, Workbook};
impl<'w> Sheet<'w> {
/// [`parse`](Self::parse) into one Arrow [`RecordBatch`], with the same `#[derive(ExcelMapper)]`
/// mapping and [`ReadOptions`].
pub fn to_arrow(&self, options: &ReadOptions) -> Result {
- let arena = build_specs::();
+ self.arrow_from_specs(&build_specs::().specs, options)
+ }
+
+ /// [`to_arrow`](Self::to_arrow) over a schema chosen at run time, such as one
+ /// [`infer_schema`](Self::infer_schema) returned. A column with a name is matched by it, one
+ /// without by its index; an empty schema is an error.
+ pub fn to_arrow_with_schema(
+ &self,
+ schema: &[InferredColumn],
+ options: &ReadOptions,
+ ) -> Result {
+ self.arrow_from_specs(&schema_specs(schema).specs, options)
+ }
+
+ /// [`to_arrow_stream`](Self::to_arrow_stream) over a schema chosen at run time, as in
+ /// [`to_arrow_with_schema`](Self::to_arrow_with_schema).
+ pub fn to_arrow_stream_with_schema(
+ &self,
+ schema: &[InferredColumn],
+ options: &ReadOptions,
+ ) -> Result, Error> {
+ self.arrow_stream_from_specs(&schema_specs(schema).specs, options)
+ }
+ fn arrow_from_specs(
+ &self,
+ specs: &[XlColumnSpec],
+ options: &ReadOptions,
+ ) -> Result {
let mut array = FFI_ArrowArray::empty();
let mut schema = FFI_ArrowSchema::empty();
@@ -30,8 +57,8 @@ impl<'w> Sheet<'w> {
ffi::xl_parse_arrow(
self.handle(),
self.index(),
- arena.specs.as_ptr(),
- arena.specs.len() as i32,
+ specs.as_ptr(),
+ specs.len() as i32,
options.header_row,
options.parallelism,
&mut array as *mut FFI_ArrowArray as *mut c_void,
@@ -55,16 +82,22 @@ impl<'w> Sheet<'w> {
&self,
options: &ReadOptions,
) -> Result, Error> {
- let arena = build_specs::();
+ self.arrow_stream_from_specs(&build_specs::().specs, options)
+ }
+ fn arrow_stream_from_specs(
+ &self,
+ specs: &[XlColumnSpec],
+ options: &ReadOptions,
+ ) -> Result, Error> {
let mut stream = FFI_ArrowArrayStream::empty();
check(unsafe {
ffi::xl_parse_arrow_stream(
self.handle(),
self.index(),
- arena.specs.as_ptr(),
- arena.specs.len() as i32,
+ specs.as_ptr(),
+ specs.len() as i32,
options.header_row,
options.batch_size,
&mut stream as *mut FFI_ArrowArrayStream as *mut c_void,
diff --git a/rust/excelreader/src/ffi.rs b/rust/excelreader/src/ffi.rs
index 9b5c8a43..80a922e9 100644
--- a/rust/excelreader/src/ffi.rs
+++ b/rust/excelreader/src/ffi.rs
@@ -148,8 +148,8 @@ pub struct XlColumnSpec {
pub nullable: i32,
}
-/// One column of a native table. Reachable from `__private` because the derive's generated
-/// bindings read it through the `column_*` accessors.
+/// One column of a native table. Crate-private: the derive's generated bindings only ever see it
+/// wrapped in a `ColumnRef`, which nothing outside this crate can construct.
#[repr(C)]
pub struct XlColumn {
pub r#type: i32,
diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs
index d7804774..fed98411 100644
--- a/rust/excelreader/src/lib.rs
+++ b/rust/excelreader/src/lib.rs
@@ -26,8 +26,8 @@ pub use rows::{AllRows, CellIter, CellRef, CellType, RowCursor, RowRef};
pub use source::Source;
pub use temporal::{Date, Time, Timestamp};
pub use workbook::{
- ColumnType, ExcelMapper, InferredColumn, Sheet, SheetVisibility, TableView, TableViewIter,
- TypedBatches, Workbook,
+ ColumnType, ExcelMapper, InferredColumn, Sheet, SheetVisibility, Table, TableBatches,
+ TableView, TableViewIter, TypedBatches, Workbook,
};
pub use writer::{
encrypt_package, write_columns, write_columns_to, write_sheet, write_sheet_to, Column,
@@ -42,10 +42,9 @@ pub use self::arrow::ArrowBatches;
/// follows semver.
#[doc(hidden)]
pub mod __private {
- pub use crate::ffi::XlColumn;
pub use crate::workbook::{
column_bool, column_date, column_f64, column_i64, column_str, column_time,
- column_timestamp, ColumnBinding,
+ column_timestamp, ColumnBinding, ColumnRef,
};
pub use crate::writer::{push_str, set_valid, OwnedColumn, OwnedColumnData};
diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs
index 4071abf6..6148fb51 100644
--- a/rust/excelreader/src/workbook.rs
+++ b/rust/excelreader/src/workbook.rs
@@ -3,7 +3,8 @@ use crate::ffi::{
XL_BUFFER_TOO_SMALL, XL_EOF, XL_OK,
};
use crate::{
- AllRows, Date, Error, ErrorKind, OpenOptions, ReadOptions, RowCursor, RowRef, Time, Timestamp,
+ AllRows, Column, ColumnData, Date, Error, ErrorKind, OpenOptions, ReadOptions, RowCursor,
+ RowRef, Time, Timestamp,
};
use std::marker::PhantomData;
use std::path::Path;
@@ -410,6 +411,59 @@ impl<'w> Sheet<'w> {
TableView::new(table, arena.bindings, "xl_parse_typed")
}
+ /// [`parse`](Self::parse) over a schema chosen at run time, such as one
+ /// [`infer_schema`](Self::infer_schema) returned. A column with a name is matched by it, one
+ /// without by its index; an empty schema is an error.
+ pub fn parse_with_schema(
+ &self,
+ schema: &[InferredColumn],
+ options: &ReadOptions,
+ ) -> Result
{
+ let specs = schema_specs(schema);
+ let mut table = empty_table();
+ check(unsafe {
+ ffi::xl_parse_typed(
+ self.workbook.handle,
+ self.index,
+ specs.specs.as_ptr(),
+ specs.specs.len() as i32,
+ options.header_row,
+ options.parallelism,
+ &mut table,
+ )
+ })?;
+ Table::new(table, schema, "xl_parse_typed")
+ }
+
+ /// [`parse_with_schema`](Self::parse_with_schema) delivered [`ReadOptions`]'s batch size rows
+ /// at a time, as [`parse_batches`](Self::parse_batches) does. `schema` is copied, so it need not
+ /// outlive the batches.
+ pub fn parse_batches_with_schema(
+ &self,
+ schema: &[InferredColumn],
+ options: &ReadOptions,
+ ) -> Result, Error> {
+ let specs = schema_specs(schema);
+ let mut reader: *mut ffi::XlTypedReader = std::ptr::null_mut();
+ check(unsafe {
+ ffi::xl_typed_reader_open(
+ self.workbook.handle,
+ self.index,
+ specs.specs.as_ptr(),
+ specs.specs.len() as i32,
+ options.header_row,
+ options.batch_size,
+ &mut reader,
+ )
+ })?;
+ Ok(TableBatches {
+ reader,
+ schema: schema.to_vec(),
+ done: false,
+ _workbook: PhantomData,
+ })
+ }
+
/// [`parse`](Self::parse) delivered [`ReadOptions`]'s batch size rows at a time; a negative
/// batch size is an error. The header row is consumed once, here, not re-read per batch.
pub fn parse_batches(
@@ -614,7 +668,7 @@ pub struct ColumnBinding {
/// Candidate header names, in priority order — the first one present in the header row wins.
pub names: &'static [&'static str],
pub column_type: ColumnType,
- pub assign: fn(&mut T, &XlColumn, i64),
+ pub assign: fn(&mut T, ColumnRef<'_>, i64),
}
// Hand-written: `#[derive(Clone)]` would add a `T: Clone` bound the user's row struct need not meet.
@@ -635,6 +689,21 @@ pub trait ExcelMapper: Sized {
pub use excelreader_derive::ExcelMapper;
+/// A column of a table the native library returned. Only this crate can build one, which is what
+/// lets the safe `column_*` accessors trust its pointers: a hand-built `XlColumn` never reaches them.
+///
+/// ```compile_fail
+/// #![forbid(unsafe_code)]
+/// let column = excelreader::__private::XlColumn {
+/// r#type: 1, length: 1,
+/// values: std::ptr::null(), validity: std::ptr::null(),
+/// data: std::ptr::null(), data_len: 0,
+/// };
+/// excelreader::__private::column_i64(excelreader::__private::ColumnRef(&column), 0);
+/// ```
+#[derive(Clone, Copy)]
+pub struct ColumnRef<'a>(&'a XlColumn);
+
fn is_valid(col: &XlColumn, row: i64) -> bool {
if col.validity.is_null() {
return true;
@@ -674,7 +743,8 @@ fn check_access(col: &XlColumn, row: i64, expected_type: i32, accessor: &str) {
/// # Panics
/// If `col` is not a string column, if `row` is out of bounds, or if the native library returned
/// bytes that are not valid UTF-8 (an ABI contract violation).
-pub fn column_str(col: &XlColumn, row: i64) -> &str {
+pub fn column_str<'a>(col: ColumnRef<'a>, row: i64) -> &'a str {
+ let col = col.0;
check_access(col, row, ffi::XL_T_STRING, "column_str");
unsafe {
let offsets = col.values as *const i32;
@@ -689,28 +759,32 @@ pub fn column_str(col: &XlColumn, row: i64) -> &str {
/// # Panics
/// If `col` is not an `i64` column, or `row` is out of bounds.
-pub fn column_i64(col: &XlColumn, row: i64) -> i64 {
+pub fn column_i64(col: ColumnRef<'_>, row: i64) -> i64 {
+ let col = col.0;
check_access(col, row, ffi::XL_T_I64, "column_i64");
unsafe { *(col.values as *const i64).offset(row as isize) }
}
/// # Panics
/// If `col` is not an `f64` column, or `row` is out of bounds.
-pub fn column_f64(col: &XlColumn, row: i64) -> f64 {
+pub fn column_f64(col: ColumnRef<'_>, row: i64) -> f64 {
+ let col = col.0;
check_access(col, row, ffi::XL_T_F64, "column_f64");
unsafe { *(col.values as *const f64).offset(row as isize) }
}
/// # Panics
/// If `col` is not a bool column, or `row` is out of bounds.
-pub fn column_bool(col: &XlColumn, row: i64) -> bool {
+pub fn column_bool(col: ColumnRef<'_>, row: i64) -> bool {
+ let col = col.0;
check_access(col, row, ffi::XL_T_BOOL, "column_bool");
unsafe { *(col.values as *const u8).offset(row as isize) != 0 }
}
/// # Panics
/// If `col` is not a date column, or `row` is out of bounds.
-pub fn column_date(col: &XlColumn, row: i64) -> Date {
+pub fn column_date(col: ColumnRef<'_>, row: i64) -> Date {
+ let col = col.0;
check_access(col, row, ffi::XL_T_DATE, "column_date");
Date {
days_since_epoch: unsafe { *(col.values as *const i32).offset(row as isize) },
@@ -719,7 +793,8 @@ pub fn column_date(col: &XlColumn, row: i64) -> Date {
/// # Panics
/// If `col` is not a time column, or `row` is out of bounds.
-pub fn column_time(col: &XlColumn, row: i64) -> Time {
+pub fn column_time(col: ColumnRef<'_>, row: i64) -> Time {
+ let col = col.0;
check_access(col, row, ffi::XL_T_TIME, "column_time");
Time {
micros_since_midnight: unsafe { *(col.values as *const i64).offset(row as isize) },
@@ -728,7 +803,8 @@ pub fn column_time(col: &XlColumn, row: i64) -> Time {
/// # Panics
/// If `col` is not a timestamp column, or `row` is out of bounds.
-pub fn column_timestamp(col: &XlColumn, row: i64) -> Timestamp {
+pub fn column_timestamp(col: ColumnRef<'_>, row: i64) -> Timestamp {
+ let col = col.0;
check_access(col, row, ffi::XL_T_TIMESTAMP, "column_timestamp");
Timestamp {
micros_since_epoch: unsafe { *(col.values as *const i64).offset(row as isize) },
@@ -803,7 +879,7 @@ impl TableView {
};
for (col, binding) in columns.iter().zip(self.bindings.iter()) {
if is_valid(col, row) {
- (binding.assign)(&mut instance, col, row);
+ (binding.assign)(&mut instance, ColumnRef(col), row);
}
}
Some(instance)
@@ -841,6 +917,126 @@ impl std::fmt::Debug for TableView {
}
}
+/// Owns a [`Sheet::parse_with_schema`] result. Frees the native table on `Drop`.
+///
+/// Its columns are the [`Column`]s [`write_columns`](crate::write_columns) takes, borrowed from
+/// this table. A column matched by index has no name, and writing needs every column named or none.
+pub struct Table {
+ table: XlTable,
+ names: Vec