From cc22ba66e4b0d05a45c68f9a38ac99b1c09d33c9 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 15:20:19 -0300 Subject: [PATCH 01/49] refactor(core)!: rename the XLSX, XLSB and XLS readers to workbooks XlsxReader, XlsbReader and XlsReader become XlsxWorkbook, XlsbWorkbook and XlsWorkbook. The internal XLSB parsing helper that held the name becomes XlsbWorkbookParts. No member or behaviour changes. The old names are recorded as removed in PublicAPI.Unshipped.txt. Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Core/Crypto/CfbContainer.cs | 2 +- .../Parser/AsyncRowEnumerator.cs | 2 +- .../Parser/ExcelEnumerable.cs | 4 +- src/ExcelReader.Core/Parser/ExcelParser.cs | 10 +- .../PublicAPI/PublicAPI.Unshipped.txt | 142 +++++++++ src/ExcelReader.Core/Reader/Excel.cs | 96 +++--- .../Reader/IExcelRowReader.cs | 2 +- .../Reader/Xls/WorkbookStream.cs | 2 +- .../Reader/Xls/XlsCompoundFile.cs | 2 +- ...numerator.cs => XlsWorkbook.Enumerator.cs} | 8 +- ...ader.Strings.cs => XlsWorkbook.Strings.cs} | 2 +- .../Xls/{XlsReader.cs => XlsWorkbook.cs} | 14 +- .../Reader/Xlsb/XlsbReader.cs | 295 ----------------- ...umerator.cs => XlsbWorkbook.Enumerator.cs} | 12 +- .../Reader/Xlsb/XlsbWorkbook.cs | 297 ++++++++++++++++-- .../Reader/Xlsb/XlsbWorkbookParts.cs | 66 ++++ ...umerator.cs => XlsxWorkbook.Enumerator.cs} | 10 +- ...der.Loading.cs => XlsxWorkbook.Loading.cs} | 2 +- ...eader.Memory.cs => XlsxWorkbook.Memory.cs} | 2 +- ...eader.Styles.cs => XlsxWorkbook.Styles.cs} | 2 +- .../Xlsx/{XlsxReader.cs => XlsxWorkbook.cs} | 22 +- tests/ExcelReader.AotSanity/Program.cs | 4 +- .../Read/StringHeavyReadBenchmark.cs | 18 +- .../Read/XlsxSharedStringHotPathBenchmark.cs | 6 +- .../RealData/RealDataReadBenchmark.cs | 36 +-- .../RealData/RealDataTypedParseBenchmark.cs | 4 +- tests/ExcelReader.Fuzz/Harnesses.cs | 10 +- .../Arrow/ArrowWriteExtensionsTests.cs | 10 +- .../Crypto/EncryptedIntegrityTests.cs | 2 +- .../Crypto/EncryptedOpenTests.cs | 12 +- .../Generator/GeneratorTests.cs | 6 +- .../Native/NativeApiTests.Writer.cs | 8 +- .../Parser/ExcelRowMapBuilderTests.cs | 18 +- .../Parser/FluentMappingTests.cs | 28 +- .../Parser/GeneratedMapParityTests.cs | 48 +-- .../Parser/HeaderCollisionTests.cs | 2 +- .../ParallelCsv/AggregateCsvModelTests.cs | 4 +- .../Reader/AsyncEnumerationTests.cs | 4 +- .../Reader/ConcurrencyContractTests.cs | 10 +- .../Reader/DialectRobustnessTests.cs | 28 +- .../Reader/ExcelDataReaderTests.cs | 4 +- .../Reader/ExcelOpenAndOleErrorTests.cs | 50 +-- .../Reader/Internal/SharedStringCacheTests.cs | 8 +- .../Reader/MemorySourceParityTests.cs | 10 +- .../Reader/MidStreamCancellationTests.cs | 12 +- .../Reader/ReaderLimitTests.cs | 32 +- .../Reader/SchemaInferenceTests.cs | 2 +- .../Reader/SheetVisibilityTests.cs | 12 +- .../Reader/SourceParityTests.cs | 6 +- .../Reader/Xls/XlsLifetimeTests.cs | 16 +- ...{XlsReaderTests.cs => XlsWorkbookTests.cs} | 4 +- .../Reader/Xlsb/XlsbEnumeratorTests.cs | 10 +- .../Reader/Xlsb/XlsbLifetimeTests.cs | 16 +- .../Reader/Xlsb/XlsbPartsTests.cs | 8 +- .../Reader/Xlsb/XlsbStructureTests.cs | 2 +- .../Reader/Xlsx/MemoryZipParityTests.cs | 28 +- .../Xlsx/NamespacePrefixAndIsoDateTests.cs | 56 ++-- .../Reader/Xlsx/PrefetchDecompressionTests.cs | 26 +- .../Reader/Xlsx/RealWorldXlsxCorpusTests.cs | 4 +- .../Reader/Xlsx/XlsxDialectShapeTests.cs | 10 +- .../Reader/Xlsx/XlsxLifetimeTests.cs | 38 +-- .../Xlsx/XlsxProducerDialectShapeTests.cs | 4 +- ...lsxReaderTests.cs => XlsxWorkbookTests.cs} | 52 +-- .../Reader/Xlsx/XlsxXmlDialectTests.cs | 32 +- .../Writer/WorkbookWriterTests.cs | 8 +- .../Writer/WriteOffloadTests.cs | 16 +- .../Writer/WriterSharedStringTests.cs | 10 +- .../Writer/Xlsb/XlsbWriterTests.cs | 16 +- 68 files changed, 943 insertions(+), 801 deletions(-) rename src/ExcelReader.Core/Reader/Xls/{XlsReader.Enumerator.cs => XlsWorkbook.Enumerator.cs} (97%) rename src/ExcelReader.Core/Reader/Xls/{XlsReader.Strings.cs => XlsWorkbook.Strings.cs} (98%) rename src/ExcelReader.Core/Reader/Xls/{XlsReader.cs => XlsWorkbook.cs} (96%) delete mode 100644 src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs rename src/ExcelReader.Core/Reader/Xlsb/{XlsbReader.Enumerator.cs => XlsbWorkbook.Enumerator.cs} (96%) create mode 100644 src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbookParts.cs rename src/ExcelReader.Core/Reader/Xlsx/{XlsxReader.Enumerator.cs => XlsxWorkbook.Enumerator.cs} (99%) rename src/ExcelReader.Core/Reader/Xlsx/{XlsxReader.Loading.cs => XlsxWorkbook.Loading.cs} (99%) rename src/ExcelReader.Core/Reader/Xlsx/{XlsxReader.Memory.cs => XlsxWorkbook.Memory.cs} (94%) rename src/ExcelReader.Core/Reader/Xlsx/{XlsxReader.Styles.cs => XlsxWorkbook.Styles.cs} (97%) rename src/ExcelReader.Core/Reader/Xlsx/{XlsxReader.cs => XlsxWorkbook.cs} (88%) rename tests/ExcelReader.Tests/Reader/Xls/{XlsReaderTests.cs => XlsWorkbookTests.cs} (99%) rename tests/ExcelReader.Tests/Reader/Xlsx/{XlsxReaderTests.cs => XlsxWorkbookTests.cs} (80%) diff --git a/src/ExcelReader.Core/Crypto/CfbContainer.cs b/src/ExcelReader.Core/Crypto/CfbContainer.cs index a6e38190..3450732b 100644 --- a/src/ExcelReader.Core/Crypto/CfbContainer.cs +++ b/src/ExcelReader.Core/Crypto/CfbContainer.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Crypto { - [ExcludeFromCodeCoverage(Justification = "Covered through XlsReader integration tests; most uncovered paths are corrupt-OLE guard rails.")] + [ExcludeFromCodeCoverage(Justification = "Covered through XlsWorkbook integration tests; most uncovered paths are corrupt-OLE guard rails.")] internal sealed class CfbContainer : IDisposable { private const int HeaderSize = 512; diff --git a/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs b/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs index e27c5e0d..5fe304f0 100644 --- a/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs +++ b/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs @@ -15,7 +15,7 @@ namespace ExcelReader.Core.Parser /// whenever the row-enumerator call and the projection both resolve synchronously (the common /// case), only falling to an awaiting continuation on a genuine buffer miss — this avoids paying /// for a second state machine on top of the row-enumerator's own (e.g. - /// XlsxReader.Enumerator.MoveNextAsync / CsvReader.Enumerator.MoveNextAsync). + /// XlsxWorkbook.Enumerator.MoveNextAsync / CsvReader.Enumerator.MoveNextAsync). /// [EditorBrowsable(EditorBrowsableState.Never)] public abstract class AsyncRowEnumerator : IAsyncEnumerator diff --git a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs index be62d5aa..e5094ad3 100644 --- a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs @@ -9,10 +9,10 @@ namespace ExcelReader.Core.Parser { /// Lazily projects XLSX rows into instances, for both synchronous and asynchronous enumeration. /// The row model type to bind each row to. - public sealed class ExcelEnumerable : ExcelEnumerable + public sealed class ExcelEnumerable : ExcelEnumerable where T : allows ref struct { - internal ExcelEnumerable(XlsxReader reader, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal ExcelEnumerable(XlsxWorkbook reader, ExcelParserConfig config, TypeMapInfo explicitInfo) : base(reader, config, explicitInfo) { } diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 07fe5f8a..5b8c0f56 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -42,7 +42,7 @@ internal ExcelParser(ExcelParserConfig config, Func> info, FuncThe XLSX reader to pull rows from. /// An enumerable that yields one per data row. /// is . - public ExcelEnumerable Parse(XlsxReader reader) + public ExcelEnumerable Parse(XlsxWorkbook reader) { ArgumentNullException.ThrowIfNull(reader); return new ExcelEnumerable(reader, _config, _info()); @@ -52,20 +52,20 @@ public ExcelEnumerable Parse(XlsxReader reader) /// The XLS reader to pull rows from. /// An enumerable that yields one per data row. /// is . - public ExcelEnumerable Parse(XlsReader reader) + public ExcelEnumerable Parse(XlsWorkbook reader) { ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); + return new ExcelEnumerable(reader, _config, _info()); } /// Parses the rows of an XLSB reader into instances, lazily as the result is enumerated. /// The XLSB reader to pull rows from. /// An enumerable that yields one per data row. /// is . - public ExcelEnumerable Parse(XlsbReader reader) + public ExcelEnumerable Parse(XlsbWorkbook reader) { ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); + return new ExcelEnumerable(reader, _config, _info()); } /// Parses the rows of a format-agnostic reader (e.g. one returned by Excel.Open) into instances, lazily as the result is enumerated. Lets callers avoid pattern-matching the concrete reader type; dispatches through the interface enumerator. diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c581..fcd93eb7 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,143 @@ #nullable enable +*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Dispose() -> void +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.DisposeAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.Dispose() -> void +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNext() -> bool +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.IsDate1904.get -> bool +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.MoveToSheet(int index) -> void +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetName.get -> string! +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetNameAt(int index) -> string! +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Dispose() -> void +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.DisposeAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNext() -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.IsDate1904.get -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.MoveToSheet(int index) -> void +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetName.get -> string! +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetNameAt(int index) -> string! +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xlsb.XlsbReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Dispose() -> void +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.DisposeAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNext() -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.IsDate1904.get -> bool +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.MoveToSheet(int index) -> void +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetName.get -> string! +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetNameAt(int index) -> string! +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Reader.Xls.XlsWorkbook +ExcelReader.Core.Reader.Xls.XlsWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Dispose() -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xls.XlsWorkbook.MoveToSheet(int index) -> void +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetName.get -> string! +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetNameAt(int index) -> string! +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xls.XlsWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.MoveToSheet(int index) -> void +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetName.get -> string! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetNameAt(int index) -> string! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Dispose() -> void +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNext() -> bool +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.MoveToSheet(int index) -> void +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetName.get -> string! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetNameAt(int index) -> string! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsb(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsb(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsbAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsbFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsbFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsx(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! +static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index 001b3f9b..a907ce8f 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -24,7 +24,7 @@ public static partial class Excel /// Opens an XLSX workbook from a file path, taking ownership of the file stream. /// The path to the XLSX file. /// Resource limits and behavior toggles; when . - public static XlsxReader FromXlsxFile(string path, ExcelReaderOptions? options = null) + public static XlsxWorkbook FromXlsxFile(string path, ExcelReaderOptions? options = null) { return FromXlsx(File.OpenRead(path), leaveOpen: false, options); } @@ -33,13 +33,13 @@ public static XlsxReader FromXlsxFile(string path, ExcelReaderOptions? options = /// The stream containing the XLSX data. /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . - public static XlsxReader FromXlsx(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) + public static XlsxWorkbook FromXlsx(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { if (TryDecryptCfbStream(stream, leaveOpen, options, out Stream decrypted)) { - return new XlsxReader(decrypted, leaveOpen: false, options); + return new XlsxWorkbook(decrypted, leaveOpen: false, options); } - return new XlsxReader(stream, leaveOpen, options); + return new XlsxWorkbook(stream, leaveOpen, options); } /// @@ -50,46 +50,46 @@ public static XlsxReader FromXlsx(Stream stream, bool leaveOpen = true, ExcelRea /// /// The whole XLSX file's bytes. Must outlive the returned reader. /// Resource limits and behavior toggles; when . is ignored on this path — there is nothing left to overlap. - public static XlsxReader FromXlsx(ReadOnlyMemory data, ExcelReaderOptions? options = null) + public static XlsxWorkbook FromXlsx(ReadOnlyMemory data, ExcelReaderOptions? options = null) { ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default; if (data.Span.StartsWith(XlsCompoundFile.Signature) && EncryptedPackageOpener.IsEncryptedMemory(data, effective)) { ReadOnlyMemory plain = EncryptedPackageOpener.DecryptToMemory(data, effective); - return XlsxReader.CreateFromMemory(plain, effective); + return XlsxWorkbook.CreateFromMemory(plain, effective); } - return XlsxReader.CreateFromMemory(data, effective); + return XlsxWorkbook.CreateFromMemory(data, effective); } /// Opens a legacy binary (XLS) workbook from a file path, taking ownership of the file stream. /// The path to the XLS file. /// Resource limits and behavior toggles; when . - public static XlsReader FromXlsFile(string path, ExcelReaderOptions? options = null) + public static XlsWorkbook FromXlsFile(string path, ExcelReaderOptions? options = null) { - return new XlsReader(File.OpenRead(path), leaveOpen: false, options); + return new XlsWorkbook(File.OpenRead(path), leaveOpen: false, options); } /// Opens a legacy binary (XLS) workbook from an existing stream. /// The stream containing the XLS data. /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . - public static XlsReader FromXls(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) + public static XlsWorkbook FromXls(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { - return new XlsReader(stream, leaveOpen, options); + return new XlsWorkbook(stream, leaveOpen, options); } /// Opens a legacy binary (XLS) workbook directly from an in-memory buffer. /// The whole XLS file's bytes. Must outlive the returned reader and must not be mutated while it is in use. /// Resource limits and behavior toggles; when . - public static XlsReader FromXls(ReadOnlyMemory data, ExcelReaderOptions? options = null) + public static XlsWorkbook FromXls(ReadOnlyMemory data, ExcelReaderOptions? options = null) { - return new XlsReader(data, options); + return new XlsWorkbook(data, options); } /// Opens an XLSB (Excel binary) workbook from a file path, taking ownership of the file stream. /// The path to the XLSB file. /// Resource limits and behavior toggles; when . - public static XlsbReader FromXlsbFile(string path, ExcelReaderOptions? options = null) + public static XlsbWorkbook FromXlsbFile(string path, ExcelReaderOptions? options = null) { return FromXlsb(File.OpenRead(path), leaveOpen: false, options); } @@ -98,13 +98,13 @@ public static XlsbReader FromXlsbFile(string path, ExcelReaderOptions? options = /// The stream containing the XLSB data. /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . - public static XlsbReader FromXlsb(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) + public static XlsbWorkbook FromXlsb(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { if (TryDecryptCfbStream(stream, leaveOpen, options, out Stream decrypted)) { - return new XlsbReader(decrypted, leaveOpen: false, options); + return new XlsbWorkbook(decrypted, leaveOpen: false, options); } - return new XlsbReader(stream, leaveOpen, options); + return new XlsbWorkbook(stream, leaveOpen, options); } /// @@ -115,22 +115,22 @@ public static XlsbReader FromXlsb(Stream stream, bool leaveOpen = true, ExcelRea /// /// The whole XLSB file's bytes. Must outlive the returned reader. /// Resource limits and behavior toggles; when . is ignored on this path — there is nothing left to overlap. - public static XlsbReader FromXlsb(ReadOnlyMemory data, ExcelReaderOptions? options = null) + public static XlsbWorkbook FromXlsb(ReadOnlyMemory data, ExcelReaderOptions? options = null) { ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default; if (data.Span.StartsWith(XlsCompoundFile.Signature) && EncryptedPackageOpener.IsEncryptedMemory(data, effective)) { ReadOnlyMemory plain = EncryptedPackageOpener.DecryptToMemory(data, effective); - return XlsbReader.CreateFromMemory(plain, effective); + return XlsbWorkbook.CreateFromMemory(plain, effective); } - return XlsbReader.CreateFromMemory(data, effective); + return XlsbWorkbook.CreateFromMemory(data, effective); } /// Asynchronously opens an XLSX workbook from a file path, taking ownership of the file stream. /// The path to the XLSX file. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsxFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsxFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) { FileStream stream = OpenAsyncFile(path); return FromXlsxAsync(stream, leaveOpen: false, options, ct); @@ -141,23 +141,23 @@ public static ValueTask FromXlsxFileAsync(string path, ExcelReaderOp /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsxAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsxAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { if (TryDecryptCfbStream(stream, leaveOpen, options, out Stream decrypted)) { - return XlsxReader.CreateAsync(decrypted, leaveOpen: false, options, ct); + return XlsxWorkbook.CreateAsync(decrypted, leaveOpen: false, options, ct); } - return XlsxReader.CreateAsync(stream, leaveOpen, options, ct); + return XlsxWorkbook.CreateAsync(stream, leaveOpen, options, ct); } /// Asynchronously opens a legacy binary (XLS) workbook from a file path, taking ownership of the file stream. /// The path to the XLS file. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) { FileStream stream = OpenAsyncFile(path); - return XlsReader.CreateAsync(stream, leaveOpen: false, options, ct); + return XlsWorkbook.CreateAsync(stream, leaveOpen: false, options, ct); } /// Asynchronously opens a legacy binary (XLS) workbook from an existing stream. @@ -165,16 +165,16 @@ public static ValueTask FromXlsFileAsync(string path, ExcelReaderOpti /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { - return XlsReader.CreateAsync(stream, leaveOpen, options, ct); + return XlsWorkbook.CreateAsync(stream, leaveOpen, options, ct); } /// Asynchronously opens an XLSB (Excel binary) workbook from a file path, taking ownership of the file stream. /// The path to the XLSB file. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsbFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsbFileAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) { FileStream stream = OpenAsyncFile(path); return FromXlsbAsync(stream, leaveOpen: false, options, ct); @@ -185,13 +185,13 @@ public static ValueTask FromXlsbFileAsync(string path, ExcelReaderOp /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - public static ValueTask FromXlsbAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask FromXlsbAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { if (TryDecryptCfbStream(stream, leaveOpen, options, out Stream decrypted)) { - return XlsbReader.CreateAsync(decrypted, leaveOpen: false, options, ct); + return XlsbWorkbook.CreateAsync(decrypted, leaveOpen: false, options, ct); } - return XlsbReader.CreateAsync(stream, leaveOpen, options, ct); + return XlsbWorkbook.CreateAsync(stream, leaveOpen, options, ct); } /// Opens a CSV (or other delimited-text) source from a file path, taking ownership of the file stream. @@ -320,7 +320,7 @@ public static ExcelColumnSchema[] InferSchema(IExcelRowReader reader, int header /// Opens a workbook from a file path, auto-detecting its format (XLSX/XLSB/XLS) from the file's signature /// and taking ownership of the file stream. /// - /// Pattern-match on the returned reader against its concrete type ( / / ) to access format-specific members. + /// Pattern-match on the returned reader against its concrete type ( / / ) to access format-specific members. /// The path to the workbook file. /// Resource limits and behavior toggles; when . /// A format-agnostic backed by the concrete reader that matches the detected format. @@ -375,9 +375,9 @@ public static IExcelRowReader Open(ReadOnlyMemory data, ExcelReaderOptions } return format switch { - ExcelFileFormat.Xls => new XlsReader(data, effective), - ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(memZip!, effective), - ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(memZip!, effective), + ExcelFileFormat.Xls => new XlsWorkbook(data, effective), + ExcelFileFormat.Xlsb => XlsbWorkbook.CreateFromIndex(memZip!, effective), + ExcelFileFormat.Xlsx => XlsxWorkbook.CreateFromIndex(memZip!, effective), _ => throw new System.Diagnostics.UnreachableException(), }; } @@ -392,8 +392,8 @@ private static IExcelRowReader OpenFromPlainMemory(ReadOnlyMemory plain, E } return format switch { - ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(memZip!, options), - ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(memZip!, options), + ExcelFileFormat.Xlsb => XlsbWorkbook.CreateFromIndex(memZip!, options), + ExcelFileFormat.Xlsx => XlsxWorkbook.CreateFromIndex(memZip!, options), _ => throw new System.Diagnostics.UnreachableException(), }; } @@ -684,9 +684,9 @@ private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, Excel } return format switch { - ExcelFileFormat.Xls => new XlsReader(stream, leaveOpen, options), - ExcelFileFormat.Xlsb => XlsbReader.CreateFromIndex(zip!, effective), - ExcelFileFormat.Xlsx => XlsxReader.CreateFromIndex(zip!, effective), + ExcelFileFormat.Xls => new XlsWorkbook(stream, leaveOpen, options), + ExcelFileFormat.Xlsb => XlsbWorkbook.CreateFromIndex(zip!, effective), + ExcelFileFormat.Xlsx => XlsxWorkbook.CreateFromIndex(zip!, effective), _ => throw new System.Diagnostics.UnreachableException(), }; } @@ -695,8 +695,8 @@ private static IExcelRowReader OpenDecryptedZip(Stream decrypted, ExcelReaderOpt { ZipIndex zip = ZipIndex.Create(ByteSource.FromStream(decrypted, leaveOpen: false), options); return ClassifyZip(zip) is ExcelFileFormat.Xlsb - ? XlsbReader.CreateFromIndex(zip, options) - : XlsxReader.CreateFromIndex(zip, options); + ? XlsbWorkbook.CreateFromIndex(zip, options) + : XlsxWorkbook.CreateFromIndex(zip, options); } private static async ValueTask OpenSeekableAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options, CancellationToken ct) @@ -725,9 +725,9 @@ private static async ValueTask OpenSeekableAsync(Stream stream, } return format switch { - ExcelFileFormat.Xls => await XlsReader.CreateAsync(stream, leaveOpen, options, ct).ConfigureAwait(false), - ExcelFileFormat.Xlsb => await XlsbReader.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false), - ExcelFileFormat.Xlsx => await XlsxReader.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false), + ExcelFileFormat.Xls => await XlsWorkbook.CreateAsync(stream, leaveOpen, options, ct).ConfigureAwait(false), + ExcelFileFormat.Xlsb => await XlsbWorkbook.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false), + ExcelFileFormat.Xlsx => await XlsxWorkbook.CreateFromIndexAsync(zip!, effective, ct).ConfigureAwait(false), _ => throw new System.Diagnostics.UnreachableException(), }; } @@ -737,8 +737,8 @@ private static async ValueTask OpenDecryptedZipAsync(Stream dec ByteSource source = await ByteSource.FromStreamAsync(decrypted, leaveOpen: false, ct).ConfigureAwait(false); ZipIndex zip = await ZipIndex.CreateAsync(source, options, ct).ConfigureAwait(false); return ClassifyZip(zip) is ExcelFileFormat.Xlsb - ? await XlsbReader.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false) - : await XlsxReader.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false); + ? await XlsbWorkbook.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false) + : await XlsxWorkbook.CreateFromIndexAsync(zip, options, ct).ConfigureAwait(false); } private static void DisposeOnFailure(Stream stream, bool leaveOpen) diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs index 4d2de72d..c2e7192b 100644 --- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs +++ b/src/ExcelReader.Core/Reader/IExcelRowReader.cs @@ -30,7 +30,7 @@ public interface IExcelRowReader /// This is the generic specialized to , /// plus a sheet-navigation surface and dispose; unifying them lets the typed parser drive a format-agnostic /// reader () and lets callers walk every sheet without - /// downcasting to the concrete XlsxReader/XlsbReader/XlsReader type. + /// downcasting to the concrete XlsxWorkbook/XlsbWorkbook/XlsWorkbook type. /// /// Thread safety: enumerators obtained from one reader may be consumed on different threads, /// each by one thread at a time, including two enumerators over the same sheet. The reader's own diff --git a/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs b/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs index 78ed0d7a..7452110d 100644 --- a/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs +++ b/src/ExcelReader.Core/Reader/Xls/WorkbookStream.cs @@ -5,7 +5,7 @@ namespace ExcelReader.Core.Reader.Xls { - [ExcludeFromCodeCoverage(Justification = "Exercised through XlsReader integration tests; guard-rail branches are corrupt-OLE only.")] + [ExcludeFromCodeCoverage(Justification = "Exercised through XlsWorkbook integration tests; guard-rail branches are corrupt-OLE only.")] internal sealed class WorkbookStream : IDisposable { private const int HeaderSize = 512; diff --git a/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs b/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs index c7771f65..7e8b65c5 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsCompoundFile.cs @@ -6,7 +6,7 @@ namespace ExcelReader.Core.Reader.Xls { - [ExcludeFromCodeCoverage(Justification = "Covered through XlsReader integration tests; most uncovered paths are corrupt-OLE guard rails.")] + [ExcludeFromCodeCoverage(Justification = "Covered through XlsWorkbook integration tests; most uncovered paths are corrupt-OLE guard rails.")] internal static class XlsCompoundFile { internal static ReadOnlySpan Signature => [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; diff --git a/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs similarity index 97% rename from src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs rename to src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs index 2f8cabcd..82e65d56 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Enumerator.cs @@ -5,14 +5,14 @@ namespace ExcelReader.Core.Reader.Xls { - public sealed partial class XlsReader + public sealed partial class XlsWorkbook { - /// Enumerates the rows of a single worksheet in an , supporting both synchronous and asynchronous iteration. + /// Enumerates the rows of a single worksheet in an , supporting both synchronous and asynchronous iteration. [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Public nested enumerator is the standard foreach pattern.")] public sealed class Enumerator : IExcelRowEnumerator { - private readonly XlsReader _reader; + private readonly XlsWorkbook _reader; private readonly CancellationToken _ct; private readonly BiffCursor _cursor; private readonly CellAccumulator _acc; @@ -24,7 +24,7 @@ public sealed class Enumerator : IExcelRowEnumerator private int _rowFloor = -1; private ReaderLifetime? _lease; - internal Enumerator(XlsReader reader, int sheetOffset, CancellationToken ct = default) + internal Enumerator(XlsWorkbook reader, int sheetOffset, CancellationToken ct = default) { _reader = reader; _ct = ct; diff --git a/src/ExcelReader.Core/Reader/Xls/XlsReader.Strings.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Strings.cs similarity index 98% rename from src/ExcelReader.Core/Reader/Xls/XlsReader.Strings.cs rename to src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Strings.cs index c4891c0f..948b6757 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsReader.Strings.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.Strings.cs @@ -4,7 +4,7 @@ namespace ExcelReader.Core.Reader.Xls { - public sealed partial class XlsReader + public sealed partial class XlsWorkbook { private const string _cp1252 = "\u20AC\u0081\u201A\u0192\u201E\u2026\u2020\u2021" + "\u02C6\u2030\u0160\u2039\u0152\u008D\u017D\u008F" + diff --git a/src/ExcelReader.Core/Reader/Xls/XlsReader.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs similarity index 96% rename from src/ExcelReader.Core/Reader/Xls/XlsReader.cs rename to src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs index 89ef3229..44aafd04 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsReader.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs @@ -6,7 +6,7 @@ namespace ExcelReader.Core.Reader.Xls { /// Reads rows from a legacy BIFF8 (.xls) workbook, exposing each worksheet through synchronous and asynchronous enumeration. - public sealed partial class XlsReader : IExcelRowReader, IExcelRowReader + public sealed partial class XlsWorkbook : IExcelRowReader, IExcelRowReader { private readonly WorkbookStream _workbook; private readonly ExcelReaderOptions _options; @@ -20,17 +20,17 @@ public sealed partial class XlsReader : IExcelRowReader, IExcelRowReader data, ExcelReaderOptions? options = null) + internal XlsWorkbook(ReadOnlyMemory data, ExcelReaderOptions? options = null) : this(XlsCompoundFile.OpenWorkbook(data, options), options) { } - private XlsReader(WorkbookStream workbook, ExcelReaderOptions? options = null) + private XlsWorkbook(WorkbookStream workbook, ExcelReaderOptions? options = null) { Lifetime = new ReaderLifetime(ReleaseResources); _workbook = workbook; @@ -46,10 +46,10 @@ private XlsReader(WorkbookStream workbook, ExcelReaderOptions? options = null) } } - internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) + internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) { WorkbookStream workbook = await XlsCompoundFile.OpenWorkbookAsync(stream, leaveOpen, options, ct).ConfigureAwait(false); - return new XlsReader(workbook, options); + return new XlsWorkbook(workbook, options); } internal BiffCursor OpenCursor(int offset) @@ -144,7 +144,7 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator() } /// - /// XlsReader is fully in-memory, so nothing is deferred; is checked here and on each move. + /// XlsWorkbook is fully in-memory, so nothing is deferred; is checked here and on each move. public Enumerator GetAsyncEnumerator(CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs deleted file mode 100644 index e46b52d2..00000000 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Buffers; -using ExcelReader.Core.Reader.Internal; -using ExcelReader.Core.Reader.Sources; -using ExcelReader.Core.Reader.Zip; - -namespace ExcelReader.Core.Reader.Xlsb -{ - /// Reads rows from a binary Excel (.xlsb / BIFF12) workbook, streaming each sheet's cells without loading the whole file into memory. - /// - /// Uses the same ZIP/OPC container as .xlsx, but worksheet parts are binary BIFF12 records. The workbook, - /// styles, and shared-string parts are read once at open time (they're small); worksheets are streamed on - /// demand by the enumerator. - /// - public sealed partial class XlsbReader : IExcelRowReader, IExcelRowReader - { - private readonly byte[] _sharedFlat = []; - private readonly int[] _sharedOffsets = [0]; - private readonly bool _pooledSharedFlat; - private string?[]? _sharedStringCache; - private readonly bool[] _styleIsDate = []; - private readonly ExcelReaderOptions _options; - private readonly DecompressedByteCounter _decompressedBytes; - - private readonly ZipIndex? _zip; - private readonly (string Name, string Path, ExcelSheetVisibility Visibility)[]? _sheets; - private int _current; - internal ReaderLifetime Lifetime { get; } - - internal XlsbReader(byte[] sharedFlat, int[] sharedOffsets, bool[] styleIsDate, bool date1904) - { - Lifetime = new ReaderLifetime(ReleaseResources); - _options = ExcelReaderOptions.Default; - _decompressedBytes = new DecompressedByteCounter(_options.MaxTotalDecompressedBytes); - _sharedFlat = sharedFlat; - _sharedOffsets = sharedOffsets; - _styleIsDate = styleIsDate; - IsDate1904 = date1904; - } - - internal XlsbReader(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null) - : this(ZipIndex.Create(ByteSource.FromStream(stream, leaveOpen), options ?? ExcelReaderOptions.Default), options ?? ExcelReaderOptions.Default) - { - } - - private XlsbReader(ZipIndex zip, ExcelReaderOptions options) - { - Lifetime = new ReaderLifetime(ReleaseResources); - _zip = zip; - _options = options; - _decompressedBytes = new DecompressedByteCounter(options.MaxTotalDecompressedBytes); - try - { - using ZipPart wbPart = zip.OpenPartOrDefault("xl/workbook.bin"u8, _decompressedBytes); - using ZipPart relsPart = zip.OpenPartOrDefault("xl/_rels/workbook.bin.rels"u8, _decompressedBytes); - _sheets = XlsbWorkbook.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span); - if (_sheets.Length == 0) - { - throw new InvalidDataException("The workbook contains no sheets."); - } - using ZipPart stylesPart = zip.OpenPartOrDefault("xl/styles.bin"u8, _decompressedBytes); - _styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span); - IsDate1904 = XlsbWorkbook.ParseDate1904(wbPart.Memory.Span); - (_sharedFlat, _sharedOffsets, _pooledSharedFlat) = LoadSharedStrings(zip, _decompressedBytes, options); - } - catch - { - zip.Dispose(); - throw; - } - } - - private XlsbReader(ZipIndex zip, - (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets, bool[] styleIsDate, bool date1904, - byte[] sharedFlat, int[] sharedOffsets, bool pooledSharedFlat, ExcelReaderOptions options, DecompressedByteCounter decompressedBytes) - { - Lifetime = new ReaderLifetime(ReleaseResources); - _zip = zip; - _options = options; - _decompressedBytes = decompressedBytes; - _sheets = sheets; - _styleIsDate = styleIsDate; - IsDate1904 = date1904; - _sharedFlat = sharedFlat; - _sharedOffsets = sharedOffsets; - _pooledSharedFlat = pooledSharedFlat; - } - - internal static XlsbReader CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null) - { - ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; - return new XlsbReader(ZipIndex.Create(data, effectiveOptions), effectiveOptions); - } - - internal static XlsbReader CreateFromIndex(ZipIndex zip, ExcelReaderOptions options) - { - return new XlsbReader(zip, options); - } - - internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) - { - ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; - ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false); - ZipIndex zip = await ZipIndex.CreateAsync(source, effectiveOptions, ct).ConfigureAwait(false); - return await CreateFromIndexAsync(zip, effectiveOptions, ct).ConfigureAwait(false); - } - - internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct) - { - try - { - DecompressedByteCounter decompressedBytes = new(options.MaxTotalDecompressedBytes); - using ZipPart wbPart = await zip.OpenPartOrDefaultAsync("xl/workbook.bin"u8, decompressedBytes, ct).ConfigureAwait(false); - using ZipPart relsPart = await zip.OpenPartOrDefaultAsync("xl/_rels/workbook.bin.rels"u8, decompressedBytes, ct).ConfigureAwait(false); - (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = XlsbWorkbook.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span); - if (sheets.Length == 0) - { - throw new InvalidDataException("The workbook contains no sheets."); - } - using ZipPart stylesPart = await zip.OpenPartOrDefaultAsync("xl/styles.bin"u8, decompressedBytes, ct).ConfigureAwait(false); - bool[] styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span); - bool date1904 = XlsbWorkbook.ParseDate1904(wbPart.Memory.Span); - (byte[] flat, int[] offsets, bool pooled) = await LoadSharedStringsAsync(zip, decompressedBytes, options, ct).ConfigureAwait(false); - return new XlsbReader(zip, sheets, styleIsDate, date1904, flat, offsets, pooled, options, decompressedBytes); - } - catch - { - zip.Dispose(); - throw; - } - } - - private static (byte[] Flat, int[] Offsets, bool Pooled) LoadSharedStrings( - ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options) - { - if (!zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry)) - { - return ([], [0], false); - } - WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options); - if (zip.HasMemory) - { - using ZipPart part = zip.OpenPart(entry, decompressedBytes, - nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes); - (byte[] parsedFlat, int[] parsedOffsets) = XlsbSharedStrings.Parse(part.Memory.Span, options); - return (parsedFlat, parsedOffsets, false); - } - using LimitedReadStream stream = zip.OpenEntryStream(entry, decompressedBytes, options, - nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes); - (byte[] flat, int[] offsets) = XlsbSharedStrings.ParseStreaming(stream, entry.UncompressedSize, options); - return (flat, offsets, flat.Length != 0); - } - - private static async ValueTask<(byte[] Flat, int[] Offsets, bool Pooled)> LoadSharedStringsAsync( - ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options, CancellationToken ct) - { - if (zip.HasMemory || !zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry)) - { - return LoadSharedStrings(zip, decompressedBytes, options); - } - WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options); - LimitedReadStream stream = await zip.OpenEntryStreamAsync(entry, decompressedBytes, options, ct, - nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - (byte[] flat, int[] offsets) = await XlsbSharedStrings.ParseStreamingAsync(stream, entry.UncompressedSize, options, ct).ConfigureAwait(false); - return (flat, offsets, flat.Length != 0); - } - } - - - /// - public bool IsDate1904 { get; } - /// - public string SheetName => _sheets![_current].Name; - /// - public int SheetCount => _sheets!.Length; - /// - public string SheetNameAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - return _sheets[index].Name; - } - /// - public ExcelSheetVisibility SheetVisibility => _sheets![_current].Visibility; - /// - public ExcelSheetVisibility SheetVisibilityAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - return _sheets[index].Visibility; - } - - /// - public bool TryMoveToSheet(ReadOnlySpan name) - { - if (!WorkbookLookups.TryFindSheetIndex(_sheets!, name, static s => s.Name, out int index)) - { - return false; - } - _current = index; - return true; - } - - /// - public void MoveToSheet(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - _current = index; - } - - - internal ReadOnlySpan SharedSpan => _sharedFlat; - - internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache(); - - private string?[] CreateSharedStringCache() - { - string?[] created = WorkbookLookups.CreateSharedStringCache(_sharedOffsets); - return Interlocked.CompareExchange(ref _sharedStringCache, created, null) ?? created; - } - - internal bool IsDateStyle(int style) - { - return WorkbookLookups.IsDateStyle(_styleIsDate, style); - } - - /// - public Enumerator GetEnumerator() - { - Lifetime.Acquire(this); - try - { - ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path); - return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); - } - catch - { - Lifetime.Release(); - throw; - } - } - - IExcelRowEnumerator IExcelRowReader.GetEnumerator() - { - return GetEnumerator(); - } - - /// - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) - { - if (_zip!.HasMemory) - { - return GetEnumerator(); - } - Lifetime.Acquire(this); - try - { - return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets![_current].Path), ct); - } - catch - { - Lifetime.Release(); - throw; - } - } - - IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) - { - return GetAsyncEnumerator(ct); - } - - - /// - public void Dispose() - { - Lifetime.Close(); - } - - /// - public ValueTask DisposeAsync() - { - Lifetime.Close(); - return ValueTask.CompletedTask; - } - - private void ReleaseResources() - { - if (_pooledSharedFlat) - { - ArrayPool.Shared.Return(_sharedFlat); - } - _zip?.Dispose(); - } - - } -} diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs similarity index 96% rename from src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs rename to src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs index 957cb697..a37658e6 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.Enumerator.cs @@ -6,9 +6,9 @@ namespace ExcelReader.Core.Reader.Xlsb { - public sealed partial class XlsbReader + public sealed partial class XlsbWorkbook { - /// Forward-only enumerator over an sheet's rows. + /// Forward-only enumerator over an sheet's rows. /// /// Streams the underlying binary sheetN.bin entry through a refillable pooled buffer; /// Biff12RecordReader framing guarantees that a partial record at the buffer boundary @@ -19,8 +19,8 @@ public sealed partial class XlsbReader public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator { [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", - Justification = "XlsbReader is borrowed; its lifetime is managed by the caller, not this enumerator.")] - private readonly XlsbReader _reader; + Justification = "XlsbWorkbook is borrowed; its lifetime is managed by the caller, not this enumerator.")] + private readonly XlsbWorkbook _reader; private readonly bool[] _styleIsDate; private readonly int[] _sharedOffsets; private readonly Utf8StringCache? _contentCache; @@ -28,7 +28,7 @@ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator private bool _ended; private bool _pendingRowHdr; - internal Enumerator(XlsbReader reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) + internal Enumerator(XlsbWorkbook reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) : base(sheet, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entryLength), ownsSource: true, ct) { _reader = reader; @@ -38,7 +38,7 @@ internal Enumerator(XlsbReader reader, Stream sheet, long entryLength = 0, Cance _lease = reader.Lifetime; } - internal Enumerator(XlsbReader reader, ZipEntryRef entry, CancellationToken ct) + internal Enumerator(XlsbWorkbook reader, ZipEntryRef entry, CancellationToken ct) : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.UncompressedSize), ct) { _reader = reader; diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index 37ea7c9b..3e4f33e5 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -1,66 +1,295 @@ -using ExcelReader.Core.Reader.Xlsx; +using System.Buffers; +using ExcelReader.Core.Reader.Internal; +using ExcelReader.Core.Reader.Sources; +using ExcelReader.Core.Reader.Zip; namespace ExcelReader.Core.Reader.Xlsb { - internal static class XlsbWorkbook + /// Reads rows from a binary Excel (.xlsb / BIFF12) workbook, streaming each sheet's cells without loading the whole file into memory. + /// + /// Uses the same ZIP/OPC container as .xlsx, but worksheet parts are binary BIFF12 records. The workbook, + /// styles, and shared-string parts are read once at open time (they're small); worksheets are streamed on + /// demand by the enumerator. + /// + public sealed partial class XlsbWorkbook : IExcelRowReader, IExcelRowReader { - internal static (string Name, string Path, ExcelSheetVisibility Visibility)[] ParseSheets(ReadOnlySpan workbookBin, ReadOnlySpan relsBytes) + private readonly byte[] _sharedFlat = []; + private readonly int[] _sharedOffsets = [0]; + private readonly bool _pooledSharedFlat; + private string?[]? _sharedStringCache; + private readonly bool[] _styleIsDate = []; + private readonly ExcelReaderOptions _options; + private readonly DecompressedByteCounter _decompressedBytes; + + private readonly ZipIndex? _zip; + private readonly (string Name, string Path, ExcelSheetVisibility Visibility)[]? _sheets; + private int _current; + internal ReaderLifetime Lifetime { get; } + + internal XlsbWorkbook(byte[] sharedFlat, int[] sharedOffsets, bool[] styleIsDate, bool date1904) + { + Lifetime = new ReaderLifetime(ReleaseResources); + _options = ExcelReaderOptions.Default; + _decompressedBytes = new DecompressedByteCounter(_options.MaxTotalDecompressedBytes); + _sharedFlat = sharedFlat; + _sharedOffsets = sharedOffsets; + _styleIsDate = styleIsDate; + IsDate1904 = date1904; + } + + internal XlsbWorkbook(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null) + : this(ZipIndex.Create(ByteSource.FromStream(stream, leaveOpen), options ?? ExcelReaderOptions.Default), options ?? ExcelReaderOptions.Default) { - Dictionary rels = XlsxXml.ParseRelationships(relsBytes); - List<(string, string, ExcelSheetVisibility)> sheets = []; - var reader = new Biff12RecordReader(workbookBin); - while (reader.TryReadRecord(out int id, out ReadOnlySpan payload)) + } + + private XlsbWorkbook(ZipIndex zip, ExcelReaderOptions options) + { + Lifetime = new ReaderLifetime(ReleaseResources); + _zip = zip; + _options = options; + _decompressedBytes = new DecompressedByteCounter(options.MaxTotalDecompressedBytes); + try { - if (id != Brt.BundleSh) + using ZipPart wbPart = zip.OpenPartOrDefault("xl/workbook.bin"u8, _decompressedBytes); + using ZipPart relsPart = zip.OpenPartOrDefault("xl/_rels/workbook.bin.rels"u8, _decompressedBytes); + _sheets = XlsbWorkbookParts.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span); + if (_sheets.Length == 0) { - continue; + throw new InvalidDataException("The workbook contains no sheets."); } - AddSheet(payload, rels, sheets); + using ZipPart stylesPart = zip.OpenPartOrDefault("xl/styles.bin"u8, _decompressedBytes); + _styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span); + IsDate1904 = XlsbWorkbookParts.ParseDate1904(wbPart.Memory.Span); + (_sharedFlat, _sharedOffsets, _pooledSharedFlat) = LoadSharedStrings(zip, _decompressedBytes, options); } - return [.. sheets]; + catch + { + zip.Dispose(); + throw; + } + } + + private XlsbWorkbook(ZipIndex zip, + (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets, bool[] styleIsDate, bool date1904, + byte[] sharedFlat, int[] sharedOffsets, bool pooledSharedFlat, ExcelReaderOptions options, DecompressedByteCounter decompressedBytes) + { + Lifetime = new ReaderLifetime(ReleaseResources); + _zip = zip; + _options = options; + _decompressedBytes = decompressedBytes; + _sheets = sheets; + _styleIsDate = styleIsDate; + IsDate1904 = date1904; + _sharedFlat = sharedFlat; + _sharedOffsets = sharedOffsets; + _pooledSharedFlat = pooledSharedFlat; + } + + internal static XlsbWorkbook CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null) + { + ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; + return new XlsbWorkbook(ZipIndex.Create(data, effectiveOptions), effectiveOptions); + } + + internal static XlsbWorkbook CreateFromIndex(ZipIndex zip, ExcelReaderOptions options) + { + return new XlsbWorkbook(zip, options); + } + + internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) + { + ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; + ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false); + ZipIndex zip = await ZipIndex.CreateAsync(source, effectiveOptions, ct).ConfigureAwait(false); + return await CreateFromIndexAsync(zip, effectiveOptions, ct).ConfigureAwait(false); } - private static void AddSheet(ReadOnlySpan payload, Dictionary rels, List<(string, string, ExcelSheetVisibility)> sheets) + internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct) { - if (payload.Length < 8) + try { - return; + DecompressedByteCounter decompressedBytes = new(options.MaxTotalDecompressedBytes); + using ZipPart wbPart = await zip.OpenPartOrDefaultAsync("xl/workbook.bin"u8, decompressedBytes, ct).ConfigureAwait(false); + using ZipPart relsPart = await zip.OpenPartOrDefaultAsync("xl/_rels/workbook.bin.rels"u8, decompressedBytes, ct).ConfigureAwait(false); + (string Name, string Path, ExcelSheetVisibility Visibility)[] sheets = XlsbWorkbookParts.ParseSheets(wbPart.Memory.Span, relsPart.Memory.Span); + if (sheets.Length == 0) + { + throw new InvalidDataException("The workbook contains no sheets."); + } + using ZipPart stylesPart = await zip.OpenPartOrDefaultAsync("xl/styles.bin"u8, decompressedBytes, ct).ConfigureAwait(false); + bool[] styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span); + bool date1904 = XlsbWorkbookParts.ParseDate1904(wbPart.Memory.Span); + (byte[] flat, int[] offsets, bool pooled) = await LoadSharedStringsAsync(zip, decompressedBytes, options, ct).ConfigureAwait(false); + return new XlsbWorkbook(zip, sheets, styleIsDate, date1904, flat, offsets, pooled, options, decompressedBytes); } - if (!Biff12.TryReadWideString(payload, 8, out ReadOnlySpan relId, out int consumed)) + catch { - return; + zip.Dispose(); + throw; } - if (!Biff12.TryReadWideString(payload, 8 + consumed, out ReadOnlySpan name, out _)) + } + + private static (byte[] Flat, int[] Offsets, bool Pooled) LoadSharedStrings( + ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options) + { + if (!zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry)) { - return; + return ([], [0], false); } - if (rels.TryGetValue(new string(relId), out string? target)) + WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options); + if (zip.HasMemory) { - sheets.Add((new string(name), XlsxXml.NormalizePart(target), Visibility(Biff12.ReadU32(payload, 0)))); + using ZipPart part = zip.OpenPart(entry, decompressedBytes, + nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes); + (byte[] parsedFlat, int[] parsedOffsets) = XlsbSharedStrings.Parse(part.Memory.Span, options); + return (parsedFlat, parsedOffsets, false); } + using LimitedReadStream stream = zip.OpenEntryStream(entry, decompressedBytes, options, + nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes); + (byte[] flat, int[] offsets) = XlsbSharedStrings.ParseStreaming(stream, entry.UncompressedSize, options); + return (flat, offsets, flat.Length != 0); } - private static ExcelSheetVisibility Visibility(uint state) + private static async ValueTask<(byte[] Flat, int[] Offsets, bool Pooled)> LoadSharedStringsAsync( + ZipIndex zip, DecompressedByteCounter decompressedBytes, ExcelReaderOptions options, CancellationToken ct) { - return state switch + if (zip.HasMemory || !zip.TryGetEntry("xl/sharedStrings.bin"u8, out ZipEntryRef entry)) { - 1 => ExcelSheetVisibility.Hidden, - 2 => ExcelSheetVisibility.VeryHidden, - _ => ExcelSheetVisibility.Visible, - }; + return LoadSharedStrings(zip, decompressedBytes, options); + } + WorkbookLookups.ThrowIfSharedEntryTooLarge(entry.UncompressedSize, decompressedBytes, options); + LimitedReadStream stream = await zip.OpenEntryStreamAsync(entry, decompressedBytes, options, ct, + nameof(ExcelReaderOptions.MaxSharedStringBytes), options.MaxSharedStringBytes).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + (byte[] flat, int[] offsets) = await XlsbSharedStrings.ParseStreamingAsync(stream, entry.UncompressedSize, options, ct).ConfigureAwait(false); + return (flat, offsets, flat.Length != 0); + } } - internal static bool ParseDate1904(ReadOnlySpan workbookBin) + + /// + public bool IsDate1904 { get; } + /// + public string SheetName => _sheets![_current].Name; + /// + public int SheetCount => _sheets!.Length; + /// + public string SheetNameAt(int index) + { + WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); + return _sheets[index].Name; + } + /// + public ExcelSheetVisibility SheetVisibility => _sheets![_current].Visibility; + /// + public ExcelSheetVisibility SheetVisibilityAt(int index) { - var reader = new Biff12RecordReader(workbookBin); - while (reader.TryReadRecord(out int id, out ReadOnlySpan payload)) + WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); + return _sheets[index].Visibility; + } + + /// + public bool TryMoveToSheet(ReadOnlySpan name) + { + if (!WorkbookLookups.TryFindSheetIndex(_sheets!, name, static s => s.Name, out int index)) { - if (id == Brt.WbProp && payload.Length >= 4) - { - return (Biff12.ReadU32(payload, 0) & 0x01) != 0; - } + return false; } - return false; + _current = index; + return true; } + + /// + public void MoveToSheet(int index) + { + WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); + _current = index; + } + + + internal ReadOnlySpan SharedSpan => _sharedFlat; + + internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache(); + + private string?[] CreateSharedStringCache() + { + string?[] created = WorkbookLookups.CreateSharedStringCache(_sharedOffsets); + return Interlocked.CompareExchange(ref _sharedStringCache, created, null) ?? created; + } + + internal bool IsDateStyle(int style) + { + return WorkbookLookups.IsDateStyle(_styleIsDate, style); + } + + /// + public Enumerator GetEnumerator() + { + Lifetime.Acquire(this); + try + { + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path); + return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); + } + catch + { + Lifetime.Release(); + throw; + } + } + + IExcelRowEnumerator IExcelRowReader.GetEnumerator() + { + return GetEnumerator(); + } + + /// + public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + if (_zip!.HasMemory) + { + return GetEnumerator(); + } + Lifetime.Acquire(this); + try + { + return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets![_current].Path), ct); + } + catch + { + Lifetime.Release(); + throw; + } + } + + IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) + { + return GetAsyncEnumerator(ct); + } + + + /// + public void Dispose() + { + Lifetime.Close(); + } + + /// + public ValueTask DisposeAsync() + { + Lifetime.Close(); + return ValueTask.CompletedTask; + } + + private void ReleaseResources() + { + if (_pooledSharedFlat) + { + ArrayPool.Shared.Return(_sharedFlat); + } + _zip?.Dispose(); + } + } } diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbookParts.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbookParts.cs new file mode 100644 index 00000000..12c3eeea --- /dev/null +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbookParts.cs @@ -0,0 +1,66 @@ +using ExcelReader.Core.Reader.Xlsx; + +namespace ExcelReader.Core.Reader.Xlsb +{ + internal static class XlsbWorkbookParts + { + internal static (string Name, string Path, ExcelSheetVisibility Visibility)[] ParseSheets(ReadOnlySpan workbookBin, ReadOnlySpan relsBytes) + { + Dictionary rels = XlsxXml.ParseRelationships(relsBytes); + List<(string, string, ExcelSheetVisibility)> sheets = []; + var reader = new Biff12RecordReader(workbookBin); + while (reader.TryReadRecord(out int id, out ReadOnlySpan payload)) + { + if (id != Brt.BundleSh) + { + continue; + } + AddSheet(payload, rels, sheets); + } + return [.. sheets]; + } + + private static void AddSheet(ReadOnlySpan payload, Dictionary rels, List<(string, string, ExcelSheetVisibility)> sheets) + { + if (payload.Length < 8) + { + return; + } + if (!Biff12.TryReadWideString(payload, 8, out ReadOnlySpan relId, out int consumed)) + { + return; + } + if (!Biff12.TryReadWideString(payload, 8 + consumed, out ReadOnlySpan name, out _)) + { + return; + } + if (rels.TryGetValue(new string(relId), out string? target)) + { + sheets.Add((new string(name), XlsxXml.NormalizePart(target), Visibility(Biff12.ReadU32(payload, 0)))); + } + } + + private static ExcelSheetVisibility Visibility(uint state) + { + return state switch + { + 1 => ExcelSheetVisibility.Hidden, + 2 => ExcelSheetVisibility.VeryHidden, + _ => ExcelSheetVisibility.Visible, + }; + } + + internal static bool ParseDate1904(ReadOnlySpan workbookBin) + { + var reader = new Biff12RecordReader(workbookBin); + while (reader.TryReadRecord(out int id, out ReadOnlySpan payload)) + { + if (id == Brt.WbProp && payload.Length >= 4) + { + return (Biff12.ReadU32(payload, 0) & 0x01) != 0; + } + } + return false; + } + } +} diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs similarity index 99% rename from src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs rename to src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs index 9b54bf4d..2c2ad493 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs @@ -8,16 +8,16 @@ namespace ExcelReader.Core.Reader.Xlsx { - public sealed partial class XlsxReader + public sealed partial class XlsxWorkbook { - /// Forward-only enumerator over an sheet's rows. + /// Forward-only enumerator over an sheet's rows. /// Low-memory: streams the sheet through a refillable pooled buffer, growing it as needed so a single <c>...</c> element is always guaranteed contiguous. [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Public nested enumerator is the standard foreach pattern.")] public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator { [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Borrowed, not owned.")] - private readonly XlsxReader _reader; + private readonly XlsxWorkbook _reader; private readonly bool[] _styleIsDate; private int[] _sharedOffsets; private readonly Utf8StringCache? _contentCache; @@ -31,7 +31,7 @@ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator private ReadOnlySpan VClose => _ns is null ? ""u8 : _ns.VClose; private ReadOnlySpan CClose => _ns is null ? ""u8 : _ns.CClose; - internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) + internal Enumerator(XlsxWorkbook reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) : base(sheet, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entryLength), ownsSource: true, ct) { _reader = reader; @@ -41,7 +41,7 @@ internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, Cance _lease = reader.Lifetime; } - internal Enumerator(XlsxReader reader, ZipEntryRef entry, CancellationToken ct) + internal Enumerator(XlsxWorkbook reader, ZipEntryRef entry, CancellationToken ct) : base(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entry.UncompressedSize), ct) { _reader = reader; diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs similarity index 99% rename from src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs rename to src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs index c7de2bb8..80df6e9f 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Loading.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Loading.cs @@ -5,7 +5,7 @@ namespace ExcelReader.Core.Reader.Xlsx { - public sealed partial class XlsxReader + public sealed partial class XlsxWorkbook { private static (string Name, string Path, ExcelSheetVisibility Visibility)[] ParseSheets(ReadOnlySpan wbBytes, ReadOnlySpan relsBytes) { diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Memory.cs similarity index 94% rename from src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs rename to src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Memory.cs index 64672c4a..3af11e86 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Memory.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Memory.cs @@ -4,7 +4,7 @@ namespace ExcelReader.Core.Reader.Xlsx { - public sealed partial class XlsxReader + public sealed partial class XlsxWorkbook { private void ParseSharedFromMemory(ReadOnlyMemory content, long entryLength) { diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Styles.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Styles.cs similarity index 97% rename from src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Styles.cs rename to src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Styles.cs index ed0b7e91..832a217d 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.Styles.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.Styles.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Reader.Xlsx { - public sealed partial class XlsxReader + public sealed partial class XlsxWorkbook { private static bool[] ParseStyleDateFlags(ReadOnlySpan src) { diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs similarity index 88% rename from src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs rename to src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index 65f65499..b39942db 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxReader.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -7,7 +7,7 @@ namespace ExcelReader.Core.Reader.Xlsx { /// Reads rows from an Office Open XML (.xlsx) workbook, streaming each sheet's cells without loading the whole file into memory. - public sealed partial class XlsxReader : IExcelRowReader, IExcelRowReader + public sealed partial class XlsxWorkbook : IExcelRowReader, IExcelRowReader { private readonly ZipIndex _zip; private readonly ExcelReaderOptions _options; @@ -24,12 +24,12 @@ public sealed partial class XlsxReader : IExcelRowReader, IExcelRowReader data, ExcelReaderOptions? options = null) + internal static XlsxWorkbook CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null) { ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; - return new XlsxReader(ZipIndex.Create(data, effectiveOptions), effectiveOptions); + return new XlsxWorkbook(ZipIndex.Create(data, effectiveOptions), effectiveOptions); } - internal static XlsxReader CreateFromIndex(ZipIndex zip, ExcelReaderOptions options) + internal static XlsxWorkbook CreateFromIndex(ZipIndex zip, ExcelReaderOptions options) { - return new XlsxReader(zip, options); + return new XlsxWorkbook(zip, options); } - internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) + internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) { ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; ByteSource source = await ByteSource.FromStreamAsync(stream, leaveOpen, ct).ConfigureAwait(false); @@ -87,7 +87,7 @@ internal static async ValueTask CreateAsync(Stream stream, bool leav return await CreateFromIndexAsync(zip, effectiveOptions, ct).ConfigureAwait(false); } - internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct) + internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, ExcelReaderOptions options, CancellationToken ct) { try { @@ -102,7 +102,7 @@ internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, E using ZipPart stylesPart = await zip.OpenPartOrDefaultAsync("xl/styles.xml"u8, decompressedBytes, ct).ConfigureAwait(false); bool[] styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span); bool date1904 = ParseDate1904(wbPart.Memory.Span); - return new XlsxReader(zip, sheets, styleIsDate, date1904, options, decompressedBytes); + return new XlsxWorkbook(zip, sheets, styleIsDate, date1904, options, decompressedBytes); } catch { diff --git a/tests/ExcelReader.AotSanity/Program.cs b/tests/ExcelReader.AotSanity/Program.cs index 0996d4ad..0c081ab4 100644 --- a/tests/ExcelReader.AotSanity/Program.cs +++ b/tests/ExcelReader.AotSanity/Program.cs @@ -13,7 +13,7 @@ internal static class Program private static async Task Main() { await using MemoryStream xlsx = await BuildSampleXlsxAsync(); - await using XlsxReader reader = await Excel.FromXlsxAsync(xlsx); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(xlsx); var rows = ExcelParser.Generated().Parse(reader).ToList(); if (rows.Count != 1 || !string.Equals(rows[0].Name, "Alice", StringComparison.Ordinal) || rows[0].Age != 30 || !rows[0].Active) { @@ -37,7 +37,7 @@ await sheet.WriteRecordsAsync([new GeneratedAotModel { Name = "Zoe", Age = 8, Ac ExcelRecordLayout.Generated()); } writtenStream.Position = 0; - await using XlsxReader writtenReader = await Excel.FromXlsxAsync(writtenStream); + await using XlsxWorkbook writtenReader = await Excel.FromXlsxAsync(writtenStream); var writtenRows = ExcelParser.Generated().Parse(writtenReader).ToList(); if (writtenRows.Count != 1 || !string.Equals(writtenRows[0].Name, "Zoe", StringComparison.Ordinal) || writtenRows[0].Age != 8 || !writtenRows[0].Active) { diff --git a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs index 019978a3..c5068d03 100644 --- a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs @@ -46,7 +46,7 @@ private static MemoryStream Open(byte[] fixture, string benchmark) public long Xlsx_ExcelReader() { using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader)); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -56,7 +56,7 @@ public long Xlsx_ExcelReader() public long Xlsx_ExcelReader_Prefetch() { using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Prefetch)); - using XlsxReader reader = Excel.FromXlsx(ms, options: _prefetchOptions); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -74,7 +74,7 @@ public long Xlsx_Sylvan() public long Xlsx_ExcelReader_Materialized() { using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Materialized)); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -84,7 +84,7 @@ public long Xlsx_ExcelReader_Materialized() public long Xlsx_ExcelReader_Materialized_Interned() { using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Materialized_Interned)); - using XlsxReader reader = Excel.FromXlsx(ms, options: _internOptions); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _internOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -95,7 +95,7 @@ public long Xlsx_ExcelReader_Materialized_Interned() public long Xlsb_ExcelReader() { using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader)); - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -105,7 +105,7 @@ public long Xlsb_ExcelReader() public long Xlsb_ExcelReader_Prefetch() { using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Prefetch)); - using XlsbReader reader = Excel.FromXlsb(ms, options: _prefetchOptions); + using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -123,7 +123,7 @@ public long Xlsb_Sylvan() public long Xlsb_ExcelReader_Materialized() { using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Materialized)); - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -133,7 +133,7 @@ public long Xlsb_ExcelReader_Materialized() public long Xlsb_ExcelReader_Materialized_Interned() { using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Materialized_Interned)); - using XlsbReader reader = Excel.FromXlsb(ms, options: _internOptions); + using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _internOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -146,7 +146,7 @@ public long Xlsb_ExcelReader_Memory() { throw new InvalidOperationException($"{nameof(Xlsb_ExcelReader_Memory)} is absent from every [GlobalSetup(Targets = ...)] list, so its fixture was never built."); } - using XlsbReader reader = Excel.FromXlsb(new ReadOnlyMemory(_xlsb)); + using XlsbWorkbook reader = Excel.FromXlsb(new ReadOnlyMemory(_xlsb)); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; diff --git a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs index 461ed4ee..ee513e6d 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs @@ -55,7 +55,7 @@ private static byte[] Restore(byte[] source) private static long ReadAll(byte[] workbook, ExcelReaderOptions? options) { using var ms = new MemoryStream(workbook, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -64,8 +64,8 @@ private static long ReadAll(byte[] workbook, ExcelReaderOptions? options) private static bool Prologue(byte[] workbook) { using var ms = new MemoryStream(workbook, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator rows = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator rows = reader.GetEnumerator(); return rows.MoveNext(); } diff --git a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs index 9ec049c2..6e704ac9 100644 --- a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs @@ -40,7 +40,7 @@ public void Setup() public long Xlsx_ExcelReader() { using MemoryStream ms = new(_xlsx, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -58,7 +58,7 @@ public long Xlsx_Sylvan() public long Xlsx_ExcelReader_Materialized() { using MemoryStream ms = new(_xlsx, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -68,7 +68,7 @@ public long Xlsx_ExcelReader_Materialized() public long Xlsx_ExcelReader_Prefetch() { using MemoryStream ms = new(_xlsx, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms, options: _prefetchOptions); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -77,7 +77,7 @@ public long Xlsx_ExcelReader_Prefetch() [Benchmark] public long Xlsx_ExcelReader_Memory() { - using XlsxReader reader = Excel.FromXlsx(_xlsx.AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(_xlsx.AsMemory()); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -86,7 +86,7 @@ public long Xlsx_ExcelReader_Memory() [Benchmark] public long Xlsx_ExcelReader_Memory_Prefetch() { - using XlsxReader reader = Excel.FromXlsx(_xlsx.AsMemory(), options: _prefetchOptions); + using XlsxWorkbook reader = Excel.FromXlsx(_xlsx.AsMemory(), options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -97,7 +97,7 @@ public long Xlsx_ExcelReader_Memory_Prefetch() public long Xlsm_ExcelReader() { using MemoryStream ms = new(_xlsm, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -115,7 +115,7 @@ public long Xlsm_Sylvan() public long Xlsm_ExcelReader_Materialized() { using MemoryStream ms = new(_xlsm, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -125,7 +125,7 @@ public long Xlsm_ExcelReader_Materialized() public long Xlsm_ExcelReader_Prefetch() { using MemoryStream ms = new(_xlsm, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms, options: _prefetchOptions); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -134,7 +134,7 @@ public long Xlsm_ExcelReader_Prefetch() [Benchmark] public long Xlsm_ExcelReader_Memory() { - using XlsxReader reader = Excel.FromXlsx(_xlsm.AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(_xlsm.AsMemory()); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -143,7 +143,7 @@ public long Xlsm_ExcelReader_Memory() [Benchmark] public long Xlsm_ExcelReader_Memory_Prefetch() { - using XlsxReader reader = Excel.FromXlsx(_xlsm.AsMemory(), options: _prefetchOptions); + using XlsxWorkbook reader = Excel.FromXlsx(_xlsm.AsMemory(), options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -154,7 +154,7 @@ public long Xlsm_ExcelReader_Memory_Prefetch() public long Xlsb_ExcelReader() { using MemoryStream ms = new(_xlsb, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -172,7 +172,7 @@ public long Xlsb_Sylvan() public long Xlsb_ExcelReader_Materialized() { using MemoryStream ms = new(_xlsb, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -182,7 +182,7 @@ public long Xlsb_ExcelReader_Materialized() public long Xlsb_ExcelReader_Prefetch() { using MemoryStream ms = new(_xlsb, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms, options: _prefetchOptions); + using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -191,7 +191,7 @@ public long Xlsb_ExcelReader_Prefetch() [Benchmark] public long Xlsb_ExcelReader_Memory() { - using XlsbReader reader = Excel.FromXlsb(_xlsb.AsMemory()); + using XlsbWorkbook reader = Excel.FromXlsb(_xlsb.AsMemory()); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -200,7 +200,7 @@ public long Xlsb_ExcelReader_Memory() [Benchmark] public long Xlsb_ExcelReader_Memory_Prefetch() { - using XlsbReader reader = Excel.FromXlsb(_xlsb.AsMemory(), options: _prefetchOptions); + using XlsbWorkbook reader = Excel.FromXlsb(_xlsb.AsMemory(), options: _prefetchOptions); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -211,7 +211,7 @@ public long Xlsb_ExcelReader_Memory_Prefetch() public long Xls_ExcelReader() { using MemoryStream ms = new(_xls, writable: false); - using XlsReader reader = Excel.FromXls(ms); + using XlsWorkbook reader = Excel.FromXls(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; @@ -229,7 +229,7 @@ public long Xls_Sylvan() public long Xls_ExcelReader_Materialized() { using MemoryStream ms = new(_xls, writable: false); - using XlsReader reader = Excel.FromXls(ms); + using XlsWorkbook reader = Excel.FromXls(ms); long acc = 0; foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } return acc; @@ -238,7 +238,7 @@ public long Xls_ExcelReader_Materialized() [Benchmark] public long Xls_ExcelReader_Memory() { - using XlsReader reader = Excel.FromXls(_xls.AsMemory()); + using XlsWorkbook reader = Excel.FromXls(_xls.AsMemory()); long acc = 0; foreach (Row row in reader) { acc += AccumulateRow(row); } return acc; diff --git a/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs index 5b212962..ef7441a1 100644 --- a/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs @@ -78,7 +78,7 @@ private static long Accumulate(FullRow row) public long Xlsx_ExcelParser() { using MemoryStream ms = new(_xlsx, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader)) { @@ -91,7 +91,7 @@ public long Xlsx_ExcelParser() public long Xlsb_ExcelParser() { using MemoryStream ms = new(_xlsb, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader)) { diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index 8e48cd8f..b9e8e7dc 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -40,7 +40,7 @@ internal static void Xlsx(ReadOnlySpan data) FuzzOracle.Guard(() => { using var ms = new MemoryStream(bytes, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms, leaveOpen: true, Limits); + using XlsxWorkbook reader = Excel.FromXlsx(ms, leaveOpen: true, Limits); DrainAllSheets(reader); }); } @@ -50,7 +50,7 @@ internal static void XlsxMemory(ReadOnlySpan data) byte[] bytes = data.ToArray(); FuzzOracle.Guard(() => { - using XlsxReader reader = Excel.FromXlsx(new ReadOnlyMemory(bytes), Limits); + using XlsxWorkbook reader = Excel.FromXlsx(new ReadOnlyMemory(bytes), Limits); DrainAllSheets(reader); }); } @@ -61,7 +61,7 @@ internal static void Xlsb(ReadOnlySpan data) FuzzOracle.Guard(() => { using var ms = new MemoryStream(bytes, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms, leaveOpen: true, Limits); + using XlsbWorkbook reader = Excel.FromXlsb(ms, leaveOpen: true, Limits); DrainAllSheets(reader); }); } @@ -71,7 +71,7 @@ internal static void XlsbMemory(ReadOnlySpan data) byte[] bytes = data.ToArray(); FuzzOracle.Guard(() => { - using XlsbReader reader = Excel.FromXlsb(new ReadOnlyMemory(bytes), Limits); + using XlsbWorkbook reader = Excel.FromXlsb(new ReadOnlyMemory(bytes), Limits); DrainAllSheets(reader); }); } @@ -104,7 +104,7 @@ internal static void Xls(ReadOnlySpan data) FuzzOracle.Guard(() => { using var ms = new MemoryStream(bytes, writable: false); - using XlsReader reader = Excel.FromXls(ms, leaveOpen: true, Limits); + using XlsWorkbook reader = Excel.FromXls(ms, leaveOpen: true, Limits); DrainAllSheets(reader); }); } diff --git a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs index 084d49f1..af0b7c8a 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs @@ -116,7 +116,7 @@ public void WriteRecordBatch_Xlsx_RoundTrips_Every_Supported_Type() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms.ToArray()); + using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); RecordBatch roundTripped = reader.ToArrowRecordBatch(ReadBackSchema()); AssertRoundTrips(roundTripped); @@ -206,8 +206,8 @@ public void WriteRecordBatch_WithHeaderFalse_OmitsTheHeaderRow() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms.ToArray()); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryParse(null, out long value)); Assert.Equal(3L, value); @@ -331,7 +331,7 @@ public void WriteRecordBatch_Xlsx_RoundTrips_Widened_Types() workbook.WriteRecordBatch(BuildWideBatch()); } - using XlsxReader reader = Excel.FromXlsx(ms.ToArray()); + using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); AssertWideRoundTrips(reader); } @@ -374,7 +374,7 @@ public async Task WriteRecordBatchAsync_Xlsx_RoundTrips() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms.ToArray()); + using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); RecordBatch roundTripped = reader.ToArrowRecordBatch(ReadBackSchema()); AssertRoundTrips(roundTripped); diff --git a/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs b/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs index 75d3a861..974f44f8 100644 --- a/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs +++ b/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs @@ -22,7 +22,7 @@ private static int CountRows(IExcelRowReader reader) public void Should_Read_When_Opened_From_Memory_With_Password() { byte[] bytes = EncryptedFixtures.Bytes("agile-aes256-sha512.xlsx"); - using XlsxReader reader = Excel.FromXlsx(bytes, WithPassword); + using XlsxWorkbook reader = Excel.FromXlsx(bytes, WithPassword); Assert.True(CountRows(reader) > 0); } diff --git a/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs b/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs index 4b776e50..4d138f4d 100644 --- a/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs +++ b/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs @@ -136,28 +136,28 @@ public void Should_Preserve_Corrupt_Ole_Diagnosis_Through_Excel_Open() public void Should_Open_When_Format_Specific_Entry_Point_Used() { using FileStream fs = File.OpenRead(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")); - using XlsxReader reader = Excel.FromXlsx(fs, leaveOpen: true, WithPassword("agile-aes256-sha512.xlsx")); + using XlsxWorkbook reader = Excel.FromXlsx(fs, leaveOpen: true, WithPassword("agile-aes256-sha512.xlsx")); Assert.NotEmpty(Drain(reader)); } [Fact] public void Should_Open_Encrypted_Xlsx_When_Opened_Via_FromFile_With_Password() { - using XlsxReader reader = Excel.FromXlsxFile(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx"), WithPassword("agile-aes256-sha512.xlsx")); + using XlsxWorkbook reader = Excel.FromXlsxFile(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx"), WithPassword("agile-aes256-sha512.xlsx")); Assert.NotEmpty(Drain(reader)); } [Fact] public void Should_Open_Encrypted_Xlsb_When_Opened_Via_FromXlsbFile_With_Password() { - using XlsbReader reader = Excel.FromXlsbFile(EncryptedFixtures.Path_("agile-aes256-sha512.xlsb"), WithPassword("agile-aes256-sha512.xlsb")); + using XlsbWorkbook reader = Excel.FromXlsbFile(EncryptedFixtures.Path_("agile-aes256-sha512.xlsb"), WithPassword("agile-aes256-sha512.xlsb")); Assert.NotEmpty(Drain(reader)); } [Fact] public async Task Should_Open_Encrypted_Xlsx_When_Opened_Via_FromFileAsync_With_Password() { - await using XlsxReader reader = await Excel.FromXlsxFileAsync( + await using XlsxWorkbook reader = await Excel.FromXlsxFileAsync( EncryptedFixtures.Path_("agile-aes256-sha512.xlsx"), WithPassword("agile-aes256-sha512.xlsx"), TestContext.Current.CancellationToken); Assert.NotEmpty(Drain(reader)); } @@ -165,7 +165,7 @@ public async Task Should_Open_Encrypted_Xlsx_When_Opened_Via_FromFileAsync_With_ [Fact] public async Task Should_Open_Encrypted_Xlsb_When_Opened_Via_FromXlsbFileAsync_With_Password() { - await using XlsbReader reader = await Excel.FromXlsbFileAsync( + await using XlsbWorkbook reader = await Excel.FromXlsbFileAsync( EncryptedFixtures.Path_("agile-aes256-sha512.xlsb"), WithPassword("agile-aes256-sha512.xlsb"), TestContext.Current.CancellationToken); Assert.NotEmpty(Drain(reader)); } @@ -173,7 +173,7 @@ public async Task Should_Open_Encrypted_Xlsb_When_Opened_Via_FromXlsbFileAsync_W [Fact] public void Should_Open_Encrypted_Xlsb_When_Opened_From_Memory_With_Password() { - using XlsbReader reader = Excel.FromXlsb(EncryptedFixtures.Bytes("agile-aes256-sha512.xlsb"), WithPassword("agile-aes256-sha512.xlsb")); + using XlsbWorkbook reader = Excel.FromXlsb(EncryptedFixtures.Bytes("agile-aes256-sha512.xlsb"), WithPassword("agile-aes256-sha512.xlsb")); Assert.NotEmpty(Drain(reader)); } diff --git a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs index 14995175..44944a82 100644 --- a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs +++ b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs @@ -428,7 +428,7 @@ public static async Task RunAsync() } writeStream.Position = 0; - await using XlsxReader reader = await Excel.FromXlsxAsync(writeStream); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(writeStream); var results = new List(); foreach (Model m in ExcelParser.Generated().Parse(reader)) { @@ -452,7 +452,7 @@ public static async Task RunAsync() """; [Fact] - public async Task GeneratedMapRoundTripsThroughRealXlsxReaderAndWriter() + public async Task GeneratedMapRoundTripsThroughRealXlsxWorkbookAndWriter() { (ImmutableCompilationResult result, ImmutableArray diagnostics) = RunGenerator(RoundTripSource); Assert.Empty(diagnostics); @@ -757,7 +757,7 @@ private static async Task ParseAsync(ExcelParserConfig? config, strin where T : IExcelRowMap { using MemoryStream ms = await BuildAsync(header, row); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms); try { var results = new List(); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs index 89161ff9..5213000d 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs @@ -176,8 +176,8 @@ private static NativeWriteOptions DefaultWriteOptions() private static void AssertTemporalColumnsCarryANumberFormat(string path) { using (FileStream file = File.OpenRead(path)) - using (XlsxReader reader = Excel.FromXlsx(file)) - using (XlsxReader.Enumerator rows = reader.GetEnumerator()) + using (XlsxWorkbook reader = Excel.FromXlsx(file)) + using (XlsxWorkbook.Enumerator rows = reader.GetEnumerator()) { Assert.True(rows.MoveNext()); Assert.True(rows.MoveNext()); @@ -196,8 +196,8 @@ private static void AssertTemporalColumnsCarryANumberFormat(string path) private static double ReadFirstDataSerial(string path) { using FileStream file = File.OpenRead(path); - using XlsbReader reader = Excel.FromXlsb(file); - using XlsbReader.Enumerator rows = reader.GetEnumerator(); + using XlsbWorkbook reader = Excel.FromXlsb(file); + using XlsbWorkbook.Enumerator rows = reader.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.True(rows.MoveNext()); diff --git a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs index f882f108..3d7226c2 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs @@ -70,11 +70,11 @@ public async Task MappedParserMatchesReflectionOnRead() ["Name", "Age", "BirthDate", "Active", "Kind"], ["Alice", 30, SampleBirthDate, true, "Beta"]); - await using XlsxReader mappedReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken); List mapped = ExcelParser.Generated().Parse(mappedReader).ToList(); ms.Position = 0; - await using XlsxReader reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); List reflected = ExcelParser.FromAttributes().Parse(reflectionReader).ToList(); Assert.Single(mapped); @@ -96,12 +96,12 @@ public async Task RequiredColumnMissingThrowsLikeReflection() .Property(["Age"], ExcelCellReaders.Parsable, static (ref MapBuilderTestModel m, int v) => m.Age = v, isRequired: true); TypeMapInfo info = builder.Build(); - await using XlsxReader mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); var mappedEnumerable = new ExcelEnumerable(mappedReader, new ExcelParserConfig(), info); ExcelParseException mappedEx = Assert.Throws(() => mappedEnumerable.ToList()); await using MemoryStream reflectedStream = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); - await using XlsxReader reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); ExcelParseException reflectedEx = Assert.Throws( () => ExcelParser.FromAttributes().Parse(reflectedReader).ToList()); @@ -144,8 +144,8 @@ public async Task RecordMapBuilderMatchesReflectionOnWrite() mappedStream.Position = 0; reflectedStream.Position = 0; - await using XlsxReader mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); - await using XlsxReader reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); List mapped = ExcelParser.FromAttributes().Parse(mappedReader).ToList(); List reflected = ExcelParser.FromAttributes().Parse(reflectedReader).ToList(); @@ -191,7 +191,7 @@ public async Task GeneratedLayoutRoundTripsThroughXlsx() } stream.Position = 0; - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, ct: ct); List results = ExcelParser.Generated().Parse(reader).ToList(); Assert.Single(results); @@ -214,7 +214,7 @@ public async Task GeneratedLayoutRoundTripsThroughXlsb() } stream.Position = 0; - await using XlsbReader reader = await Excel.FromXlsbAsync(stream, leaveOpen: false, ct: ct); + await using XlsbWorkbook reader = await Excel.FromXlsbAsync(stream, leaveOpen: false, ct: ct); List results = ExcelParser.Generated().Parse(reader).ToList(); Assert.Single(results); @@ -237,7 +237,7 @@ public async Task GeneratedLayoutRoundTripsThroughXls() } stream.Position = 0; - await using XlsReader reader = Excel.FromXls(stream, leaveOpen: false); + await using XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); List results = ExcelParser.Generated().Parse(reader).ToList(); Assert.Single(results); diff --git a/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs b/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs index 11ab81b9..ce496916 100644 --- a/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs +++ b/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs @@ -42,7 +42,7 @@ public async Task TwoDifferentMapsForSameModelInOneProcess() .Factory(static () => new SharedModel()) .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); - await using XlsxReader readerA = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); + await using XlsxWorkbook readerA = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); List resultA = parserA.Parse(readerA).ToList(); ms.Position = 0; @@ -50,7 +50,7 @@ public async Task TwoDifferentMapsForSameModelInOneProcess() .Factory(static () => new SharedModel()) .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant()) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v * 2)); - await using XlsxReader readerB = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook readerB = await Excel.FromXlsxAsync(ms, ct: ct); List resultB = parserB.Parse(readerB).ToList(); Assert.Equal("Alice", resultA[0].Name); @@ -69,7 +69,7 @@ public async Task ModelWithNoAttributesIsFullyMappable() .Factory(static () => new SharedModel()) .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); @@ -87,7 +87,7 @@ public async Task MapByColumnIndexWithoutHeaderRow() .Factory(static () => new SharedModel()) .PropertyAt(0, ExcelCellReaders.String, static (ref m, v) => m.Name = v) .PropertyAt(1, ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); @@ -105,7 +105,7 @@ public async Task RequiredColumnMissingByIndexThrows() .Factory(static () => new SharedModel()) .PropertyAt(0, ExcelCellReaders.String, static (ref m, v) => m.Name = v) .PropertyAt(1, ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v, requireValue: true)); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); Assert.Throws(() => parser.Parse(reader).ToList()); } @@ -118,7 +118,7 @@ public async Task FluentOverridesAttributeForConfiguredProperty() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant())); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); @@ -133,7 +133,7 @@ public async Task AttributeSurvivesForPropertyNotInBuilder() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant())); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); @@ -150,11 +150,11 @@ public async Task FluentMapMatchesAttributeMapForEquivalentConfig() .Factory(static () => new AttributedModel()) .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref AttributedModel m, int v) => m.Age = v)); - await using XlsxReader fluentReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); + await using XlsxWorkbook fluentReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); List fluentResults = fluentParser.Parse(fluentReader).ToList(); ms.Position = 0; - await using XlsxReader reflectedReader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(ms, ct: ct); List reflectedResults = ExcelParser.FromAttributes().Parse(reflectedReader).ToList(); Assert.Single(fluentResults); @@ -182,7 +182,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } } xlsxStream.Position = 0; - await using XlsxReader xlsxReader = await Excel.FromXlsxAsync(xlsxStream, ct: ct); + await using XlsxWorkbook xlsxReader = await Excel.FromXlsxAsync(xlsxStream, ct: ct); AssertMatches(record, ExcelParser.Build(configure).Parse(xlsxReader).Single()); await using var xlsbStream = new MemoryStream(); @@ -194,7 +194,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } } xlsbStream.Position = 0; - await using XlsbReader xlsbReader = await Excel.FromXlsbAsync(xlsbStream, leaveOpen: false, ct: ct); + await using XlsbWorkbook xlsbReader = await Excel.FromXlsbAsync(xlsbStream, leaveOpen: false, ct: ct); AssertMatches(record, ExcelParser.Build(configure).Parse(xlsbReader).Single()); await using var xlsStream = new MemoryStream(); @@ -206,7 +206,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } } xlsStream.Position = 0; - using XlsReader xlsReader = Excel.FromXls(xlsStream, leaveOpen: false); + using XlsWorkbook xlsReader = Excel.FromXls(xlsStream, leaveOpen: false); AssertMatches(record, ExcelParser.Build(configure).Parse(xlsReader).Single()); await using var csvStream = new MemoryStream(); @@ -230,7 +230,7 @@ public async Task FluentBindingWithDifferentHeaderDoesNotOverrideAttribute() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["arquivo"], ExcelCellReaders.String, static (ref m, v) => m.Name = v)); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); @@ -265,7 +265,7 @@ public async Task PlainConstructorAllowsValueTypeModelWithNoFactory() var parser = ExcelParser.Build(static b => b .Property(["Age"], ExcelCellReaders.Parsable, static (ref StructModel m, int v) => m.Age = v)); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); List results = parser.Parse(reader).ToList(); Assert.Single(results); diff --git a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs index 5ccc19f0..a4120778 100644 --- a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs +++ b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs @@ -437,10 +437,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXlsx() await sheet.EndAsync(TestContext.Current.CancellationToken); } ms.Position = 0; - await using XlsxReader reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); ms.Position = 0; - await using XlsxReader generatedReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook generatedReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); @@ -465,10 +465,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXlsb() await sheet.EndAsync(TestContext.Current.CancellationToken); } ms.Position = 0; - using XlsbReader reflectionReader = Excel.FromXlsb(ms); + using XlsbWorkbook reflectionReader = Excel.FromXlsb(ms); CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); ms.Position = 0; - using XlsbReader generatedReader = Excel.FromXlsb(ms); + using XlsbWorkbook generatedReader = Excel.FromXlsb(ms); CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); @@ -493,10 +493,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXls() await sheet.EndAsync(TestContext.Current.CancellationToken); } ms.Position = 0; - using XlsReader reflectionReader = Excel.FromXls(ms); + using XlsWorkbook reflectionReader = Excel.FromXls(ms); CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); ms.Position = 0; - using XlsReader generatedReader = Excel.FromXls(ms); + using XlsWorkbook generatedReader = Excel.FromXls(ms); CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); @@ -591,10 +591,10 @@ private static void AssertCrossFormatEqual(CrossFormatModel reflectionResult, Cr Assert.Equal(ParityKind.Beta, generatedResult.Category); } - private static async Task ParseFirstXlsxAsync(MemoryStream ms, Func> parse) + private static async Task ParseFirstXlsxAsync(MemoryStream ms, Func> parse) { ms.Position = 0; - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); return parse(reader).First(); } @@ -632,16 +632,16 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsx() } reflectionMs.Position = 0; - using XlsxReader reflectionReader = Excel.FromXlsx(reflectionMs); - using XlsxReader.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsxWorkbook reflectionReader = Excel.FromXlsx(reflectionMs); + using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); string[] reflectionValues = ReadRowText(reflectionEnum.Current); generatedMs.Position = 0; - using XlsxReader generatedReader = Excel.FromXlsx(generatedMs); - using XlsxReader.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsxWorkbook generatedReader = Excel.FromXlsx(generatedMs); + using XlsxWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -675,16 +675,16 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsb() } reflectionMs.Position = 0; - using XlsbReader reflectionReader = Excel.FromXlsb(reflectionMs); - using XlsbReader.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsbWorkbook reflectionReader = Excel.FromXlsb(reflectionMs); + using XlsbWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); string[] reflectionValues = ReadRowText(reflectionEnum.Current); generatedMs.Position = 0; - using XlsbReader generatedReader = Excel.FromXlsb(generatedMs); - using XlsbReader.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsbWorkbook generatedReader = Excel.FromXlsb(generatedMs); + using XlsbWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -718,16 +718,16 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXls() } reflectionMs.Position = 0; - using XlsReader reflectionReader = Excel.FromXls(reflectionMs); - using XlsReader.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsWorkbook reflectionReader = Excel.FromXls(reflectionMs); + using XlsWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); string[] reflectionValues = ReadRowText(reflectionEnum.Current); generatedMs.Position = 0; - using XlsReader generatedReader = Excel.FromXls(generatedMs); - using XlsReader.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsWorkbook generatedReader = Excel.FromXls(generatedMs); + using XlsWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -804,16 +804,16 @@ public async Task GeneratedRecordMapHeadersMatchReflectionHeaders() } reflectionMs.Position = 0; - using XlsxReader reflectionReader = Excel.FromXlsx(reflectionMs); - using XlsxReader.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsxWorkbook reflectionReader = Excel.FromXlsx(reflectionMs); + using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); string[] reflectionValues = ReadRowText(reflectionEnum.Current); generatedMs.Position = 0; - using XlsxReader generatedReader = Excel.FromXlsx(generatedMs); - using XlsxReader.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsxWorkbook generatedReader = Excel.FromXlsx(generatedMs); + using XlsxWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); diff --git a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs index 9e59bb09..bf2fee70 100644 --- a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs +++ b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs @@ -84,7 +84,7 @@ private static ExcelParser[] DiacriticParsers(ExcelParserConfig? private static async Task> ParseAsync(ExcelParser parser, object?[] header, object?[] row) { await using MemoryStream ms = await TypedWorkbook.BuildAsync(header, row); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); return [.. parser.Parse(reader)]; } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index b6e74fcb..716043ea 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -315,7 +315,7 @@ public async Task GeneratedRecordMapWritesTheSpanAsText() } stream.Position = 0; - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, ct: ct); Assert.Equal(["Ada Lovelace|7|first"], ReadPeople(reader)); } @@ -324,7 +324,7 @@ private static void WritePerson(ExcelRecordMapBuilder bui builder.WriteRow(row, new Person { Name = "Ada Lovelace"u8, Id = 7, Note = "first" }); } - private static List ReadPeople(XlsxReader reader) + private static List ReadPeople(XlsxWorkbook reader) { var rows = new List(); foreach (Person person in ExcelParser.FromAttributes().Parse(reader)) diff --git a/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs b/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs index 9d75a21b..d6a32223 100644 --- a/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs +++ b/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs @@ -15,7 +15,7 @@ public async Task XlsxAsyncEnumerationNeverReadsSynchronously() CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = await BuildAsync(s => XlsxWorkbookWriter.Create(s, leaveOpen: true)); await using var stream = new SyncGuardStream(bytes); - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, ct: ct); stream.AllowSyncReads = false; Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.GetAsyncEnumerator(ct))); @@ -27,7 +27,7 @@ public async Task XlsbAsyncEnumerationNeverReadsSynchronously() CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = await BuildAsync(s => XlsbWorkbookWriter.Create(s, leaveOpen: true)); await using var stream = new SyncGuardStream(bytes); - await using XlsbReader reader = await Excel.FromXlsbAsync(stream, ct: ct); + await using XlsbWorkbook reader = await Excel.FromXlsbAsync(stream, ct: ct); stream.AllowSyncReads = false; Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.GetAsyncEnumerator(ct))); diff --git a/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs index d95a1d50..b63e5dff 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs @@ -7,15 +7,15 @@ namespace ExcelReader.Tests.Reader public class ConcurrencyContractTests { [Fact] - public Task IndependentXlsxReadersOnSeparateThreadsDoNotInterfere() + public Task IndependentXlsxWorkbooksOnSeparateThreadsDoNotInterfere() { const int readerCount = 16; IEnumerable tasks = Enumerable.Range(0, readerCount).Select(i => Task.Run(() => { string expected = $"value-{i}"; using MemoryStream ms = WorkbookBuilder.Build($"""{expected}"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); })); @@ -42,8 +42,8 @@ public Task IndependentXlsxWorkbookWritersOnSeparateThreadsDoNotInterfere() await wb.EndAsync(TestContext.Current.CancellationToken); } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); })); diff --git a/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs b/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs index 54f3ac23..97451347 100644 --- a/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs +++ b/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs @@ -74,10 +74,10 @@ public void SingleQuotedWorkbookAttributesAreRead() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.Equal("Data", reader.SheetName); Assert.True(reader.IsDate1904); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); } @@ -88,8 +88,8 @@ public void SingleQuotedStyleAttributesDetectDate() using MemoryStream ms = WorkbookBuilder.Build( """45658""", styles: ""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -102,8 +102,8 @@ public void CorruptSharedStringIndexYieldsEmptyNotFirstString() using MemoryStream ms = WorkbookBuilder.Build( """x0""", sharedStrings: "firstsecond"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("", e.Current[0].GetString()); @@ -116,8 +116,8 @@ public void EmptySharedStringIndexYieldsEmpty() using MemoryStream ms = WorkbookBuilder.Build( """""", sharedStrings: "first"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("", e.Current[0].GetString()); @@ -128,8 +128,8 @@ public void EmptySharedStringIndexYieldsEmpty() public void TruncatedXlsbStreamThrows() { byte[] sheet = TruncatedSheet(); - using XlsbReader reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); - using XlsbReader.Enumerator e = new(reader, new MemoryStream(sheet)); + using XlsbWorkbook reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); + using XlsbWorkbook.Enumerator e = new(reader, new MemoryStream(sheet)); Assert.Throws(() => { @@ -143,8 +143,8 @@ public void TruncatedXlsbStreamThrows() public async Task TruncatedXlsbStreamThrowsAsync() { byte[] sheet = TruncatedSheet(); - await using XlsbReader reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); - await using XlsbReader.Enumerator e = new(reader, new MemoryStream(sheet)); + await using XlsbWorkbook reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); + await using XlsbWorkbook.Enumerator e = new(reader, new MemoryStream(sheet)); await Assert.ThrowsAsync(async () => { @@ -162,8 +162,8 @@ public void CompleteXlsbStreamWithoutEndMarkerDoesNotThrow() .. B.Record(Brt.RowHdr), .. B.Record(Brt.CellReal, B.CellReal(0, 0, 3.14)), ]; - using XlsbReader reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); - using XlsbReader.Enumerator e = new(reader, new MemoryStream(sheet)); + using XlsbWorkbook reader = new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); + using XlsbWorkbook.Enumerator e = new(reader, new MemoryStream(sheet)); Assert.True(e.MoveNext()); Assert.Equal(3.14, Read(e.Current[0])); diff --git a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs index c8bf18c3..572c588d 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs @@ -205,7 +205,7 @@ public async Task GetBytesFormatsAnXlsbNumericCellFromItsBinaryValue() } ms.Position = 0; - using XlsbReader reader = Excel.FromXlsb(ms); + using XlsbWorkbook reader = Excel.FromXlsb(ms); using var data = new ExcelDataReader(reader); Assert.True(data.Read()); @@ -350,7 +350,7 @@ public void AFailedConstructorDoesNotKeepTheReaderAlive() } TrickleStream stream = new(buffer.ToArray()); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxCellBytes = 64 }); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxCellBytes = 64 }); Assert.Throws(() => { using ExcelDataReader data = new(reader); diff --git a/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs b/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs index 500b56d2..24e4fe74 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs +++ b/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs @@ -16,8 +16,8 @@ public void OpenDetectsXlsxFromStream() """Hello"""); using var reader = Excel.Open(ms); - XlsxReader xlsx = Assert.IsType(reader); - using XlsxReader.Enumerator e = xlsx.GetEnumerator(); + XlsxWorkbook xlsx = Assert.IsType(reader); + using XlsxWorkbook.Enumerator e = xlsx.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Hello", e.Current[0].GetString()); } @@ -28,8 +28,8 @@ public void OpenDetectsXlsFromStream() using MemoryStream ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["A"]])]); using var reader = Excel.Open(ms); - XlsReader xls = Assert.IsType(reader); - using XlsReader.Enumerator e = xls.GetEnumerator(); + XlsWorkbook xls = Assert.IsType(reader); + using XlsWorkbook.Enumerator e = xls.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); } @@ -41,12 +41,12 @@ public async Task OpenAsyncDetectsBothFormatsFromStream() """1"""); await using (var xlsx = await Excel.OpenAsync(xlsxBytes, ct: TestContext.Current.CancellationToken)) { - Assert.IsType(xlsx); + Assert.IsType(xlsx); } await using MemoryStream xlsBytes = XlsWorkbookBuilder.Build(sheets: [("S1", [["A"]])]); await using var xls = await Excel.OpenAsync(xlsBytes, ct: TestContext.Current.CancellationToken); - Assert.IsType(xls); + Assert.IsType(xls); } [Fact] @@ -58,10 +58,10 @@ public void OpenDetectsFromFilePath() { using (var xlsx = Excel.Open(xlsxPath)) { - Assert.IsType(xlsx); + Assert.IsType(xlsx); } using var xls = Excel.Open(xlsPath); - Assert.IsType(xls); + Assert.IsType(xls); } finally { @@ -77,7 +77,7 @@ public async Task OpenAsyncDetectsFromFilePath() try { await using var reader = await Excel.OpenAsync(path, ct: TestContext.Current.CancellationToken); - Assert.IsType(reader); + Assert.IsType(reader); } finally { @@ -121,7 +121,7 @@ public void OpenLeavesSeekableStreamAtOriginalPositionForReader() using MemoryStream ms = WorkbookBuilder.Build("""7"""); ms.Position = 0; using var reader = Excel.Open(ms, leaveOpen: true); - using XlsxReader.Enumerator e = ((XlsxReader)reader).GetEnumerator(); + using XlsxWorkbook.Enumerator e = ((XlsxWorkbook)reader).GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("7", e.Current[0].GetString()); } @@ -201,7 +201,7 @@ public void DetectFileFormatLeavesStreamPositionUnchanged() Assert.Equal(before, ms.Position); using var reader = Excel.Open(ms, leaveOpen: true); - Assert.IsType(reader); + Assert.IsType(reader); } [Fact] @@ -311,9 +311,9 @@ public void NonSeekableXlsSourceIsBufferedAndRead() { byte[] bytes = XlsWorkbookBuilder.Build(sheets: [("S1", [["Name"], ["Alice"]])]).ToArray(); using NonSeekableStream stream = new(bytes); - using XlsReader reader = Excel.FromXls(stream, leaveOpen: false); + using XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); - using XlsReader.Enumerator e = reader.GetEnumerator(); + using XlsWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.True(e.MoveNext()); @@ -325,10 +325,10 @@ public async Task NonSeekableXlsSourceIsBufferedAndReadAsync() { byte[] bytes = XlsWorkbookBuilder.Build(sheets: [("S1", [["Async"]])]).ToArray(); await using NonSeekableStream stream = new(bytes); - await using XlsReader reader = await Excel.FromXlsAsync( + await using XlsWorkbook reader = await Excel.FromXlsAsync( stream, leaveOpen: false, ct: TestContext.Current.CancellationToken); - using XlsReader.Enumerator e = reader.GetEnumerator(); + using XlsWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Async", e.Current[0].GetString()); } @@ -340,8 +340,8 @@ public void FromXlsxFileOpensLikeFromFile() string path = WriteTemp(".xlsx", WorkbookBuilder.Build("""1""")); try { - using XlsxReader reader = Excel.FromXlsxFile(path); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsxFile(path); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); } @@ -355,13 +355,13 @@ public void FromXlsxFileOpensLikeFromFile() public void FromXlsxStreamAndMemoryOpenLikeFrom() { using MemoryStream ms = WorkbookBuilder.Build("""7"""); - using XlsxReader streamReader = Excel.FromXlsx(ms, leaveOpen: true); - using XlsxReader.Enumerator se = streamReader.GetEnumerator(); + using XlsxWorkbook streamReader = Excel.FromXlsx(ms, leaveOpen: true); + using XlsxWorkbook.Enumerator se = streamReader.GetEnumerator(); Assert.True(se.MoveNext()); Assert.Equal("7", se.Current[0].GetString()); - using XlsxReader memoryReader = Excel.FromXlsx(ms.ToArray().AsMemory()); - using XlsxReader.Enumerator me = memoryReader.GetEnumerator(); + using XlsxWorkbook memoryReader = Excel.FromXlsx(ms.ToArray().AsMemory()); + using XlsxWorkbook.Enumerator me = memoryReader.GetEnumerator(); Assert.True(me.MoveNext()); Assert.Equal("7", me.Current[0].GetString()); } @@ -373,8 +373,8 @@ public async Task FromXlsxFileAsyncAndFromXlsxAsyncOpenLikeTheirCanonicalCounter string path = WriteTemp(".xlsx", WorkbookBuilder.Build("""9""")); try { - await using XlsxReader fileReader = await Excel.FromXlsxFileAsync(path, ct: ct); - await using XlsxReader.Enumerator fe = fileReader.GetEnumerator(); + await using XlsxWorkbook fileReader = await Excel.FromXlsxFileAsync(path, ct: ct); + await using XlsxWorkbook.Enumerator fe = fileReader.GetEnumerator(); Assert.True(fe.MoveNext()); Assert.Equal("9", fe.Current[0].GetString()); } @@ -384,8 +384,8 @@ public async Task FromXlsxFileAsyncAndFromXlsxAsyncOpenLikeTheirCanonicalCounter } await using MemoryStream ms = WorkbookBuilder.Build("""3"""); - await using XlsxReader streamReader = await Excel.FromXlsxAsync(ms, ct: ct); - await using XlsxReader.Enumerator se = streamReader.GetEnumerator(); + await using XlsxWorkbook streamReader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook.Enumerator se = streamReader.GetEnumerator(); Assert.True(se.MoveNext()); Assert.Equal("3", se.Current[0].GetString()); } diff --git a/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs b/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs index 9c3fe56e..7234b59b 100644 --- a/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs +++ b/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs @@ -24,10 +24,10 @@ private static byte[] BuildXlsx() return buffer.ToArray(); } - private static List FirstColumn(XlsxReader reader) + private static List FirstColumn(XlsxWorkbook reader) { List values = []; - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); while (e.MoveNext()) { values.Add(e.Current[0].GetString()); @@ -38,7 +38,7 @@ private static List FirstColumn(XlsxReader reader) [Fact] public void A_Shared_String_Is_Materialized_Once_Per_Reader() { - using XlsxReader reader = Excel.FromXlsx(BuildXlsx()); + using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx()); List first = FirstColumn(reader); List second = FirstColumn(reader); @@ -53,7 +53,7 @@ public void A_Shared_String_Is_Materialized_Once_Per_Reader() [Fact] public void The_Cache_Array_Is_One_Instance() { - using XlsxReader reader = Excel.FromXlsx(BuildXlsx()); + using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx()); _ = FirstColumn(reader); string?[] seen = reader.SharedStringCache; Parallel.For(0, 32, _ => Assert.Same(seen, reader.SharedStringCache)); diff --git a/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs b/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs index e865a935..559df8a3 100644 --- a/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs @@ -41,8 +41,8 @@ public void XlsMemorySourceMatchesStreamSource() { byte[] bytes = XlsWorkbookBuilder.Build(sheets: [("S1", [["Name", 1, true]])]).ToArray(); - using XlsReader streamReader = Excel.FromXls(new MemoryStream(bytes, writable: false)); - using XlsReader memoryReader = Excel.FromXls(bytes.AsMemory()); + using XlsWorkbook streamReader = Excel.FromXls(new MemoryStream(bytes, writable: false)); + using XlsWorkbook memoryReader = Excel.FromXls(bytes.AsMemory()); AssertRowsEqual(streamReader, memoryReader); } @@ -55,9 +55,9 @@ public void XlsMemorySourceMatchesStreamSource_ForSlicedAndNonArrayBackedBuffers ReadOnlyMemory sliced = prefixed.AsMemory(3, bytes.Length); var manager = new NonArrayMemoryManager(bytes); - using XlsReader streamReader = Excel.FromXls(new MemoryStream(sliced.ToArray(), writable: false)); - using XlsReader memoryReader = Excel.FromXls(sliced); - using XlsReader managerReader = Excel.FromXls(manager.Memory); + using XlsWorkbook streamReader = Excel.FromXls(new MemoryStream(sliced.ToArray(), writable: false)); + using XlsWorkbook memoryReader = Excel.FromXls(sliced); + using XlsWorkbook managerReader = Excel.FromXls(manager.Memory); AssertRowsEqual(streamReader, memoryReader); AssertRowsEqual(streamReader, managerReader); diff --git a/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs b/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs index 32d2360a..c854c9ab 100644 --- a/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs +++ b/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs @@ -19,8 +19,8 @@ public async Task XlsxCancellationMidLargeSheetThrows() using CancellationTokenSource cts = new(); await using MemoryStream ms = new(bytes, writable: false); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: outer); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: outer); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); for (int i = 0; i < 5; i++) { @@ -70,8 +70,8 @@ public async Task XlsCancellationAfterCancelThrowsOnNextMoveNext() using MemoryStream ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["Row1"], ["Row2"], ["Row3"]])]); using CancellationTokenSource cts = new(); - await using XlsReader reader = await Excel.FromXlsAsync(ms, ct: outer); - await using XlsReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsWorkbook reader = await Excel.FromXlsAsync(ms, ct: outer); + await using XlsWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); @@ -87,8 +87,8 @@ public async Task XlsbCancellationAfterCancelThrowsOnNextMoveNext() using CancellationTokenSource cts = new(); await using MemoryStream ms = new(bytes, writable: false); - await using XlsbReader reader = await Excel.FromXlsbAsync(ms, ct: outer); - await using XlsbReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsbWorkbook reader = await Excel.FromXlsbAsync(ms, ct: outer); + await using XlsbWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); diff --git a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs index fd7f37e0..834def0f 100644 --- a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs +++ b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs @@ -52,10 +52,10 @@ public void FourOrMoreColumnLettersThrowColumnLimit(string cellRef) { using MemoryStream built = WorkbookBuilder.Build($"""1"""); - using XlsxReader reader = Excel.FromXlsx(built); + using XlsxWorkbook reader = Excel.FromXlsx(built); Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); }); } @@ -65,8 +65,8 @@ public void LastColumnXfdStillReads() { using MemoryStream built = WorkbookBuilder.Build("""1"""); - using XlsxReader reader = Excel.FromXlsx(built); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(built); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(16_384, e.Current.ColumnCount); } @@ -109,7 +109,7 @@ public void ForgedOversizedStylesEntryTripsTotalLimitBeforeReading() ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(forged, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(forged, options: options); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); Assert.Equal(50_000_000, ex.Actual); @@ -131,10 +131,10 @@ public void ForgedOversizedSharedStringsEntryTripsSharedStringLimitBeforeReading MaxSharedStringBytes = 1024, }; - using XlsxReader reader = Excel.FromXlsx(forged, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(forged, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); Assert.Equal(50_000_000, ex.Actual); @@ -158,10 +158,10 @@ public void ImplausibleUniqueCountTripsSharedStringLimitBeforeAllocating() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); Assert.Equal(500_000_000, ex.Actual); @@ -283,7 +283,7 @@ public void TooManyZipEntriesTripsMaxZipEntriesLimit() ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); }); Assert.Equal(nameof(ExcelReaderOptions.MaxZipEntries), ex.LimitName); Assert.Equal(10, ex.Limit); @@ -302,10 +302,10 @@ public void CompressedSheetTripsTotalDecompressedLimit() MaxTotalDecompressedBytes = 16 * 1024, }; - using XlsxReader reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -326,10 +326,10 @@ public void SharedStringsTripSharedStringLimit() MaxSharedStringBytes = 1024, }; - using XlsxReader reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); } @@ -346,8 +346,8 @@ public void ZeroTotalDecompressedLimitRestoresUnlimitedTotalRead() MaxTotalDecompressedBytes = 0, }; - using XlsxReader reader = Excel.FromXlsx(ms, options: options); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(value.Length, e.Current[0].GetString().Length); } diff --git a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs index 38e51795..14a613f4 100644 --- a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs +++ b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs @@ -40,7 +40,7 @@ private static ExcelColumnSchema[] InferFromCsv(string csv, int headerRow = 1, i private static ExcelColumnSchema[] InferFromXlsx(string sheetRows, int headerRow = 1, int sampleSize = 100, bool parseText = false) { using MemoryStream ms = WorkbookBuilder.Build(sheetRows); - using XlsxReader reader = Excel.FromXlsx(ms.ToArray()); + using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); using var rows = reader.GetEnumerator(); return SchemaInference.Infer(rows, isDate1904: false, headerRow, sampleSize, parseText); } diff --git a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs index deec77de..ecddfbf9 100644 --- a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs @@ -58,7 +58,7 @@ public void Should_ReportEverySheetsState_When_ReadingXlsx() byte[] xlsx = BuildXlsx(null, "hidden", "veryHidden"); using var stream = new MemoryStream(xlsx, writable: false); - using XlsxReader reader = Excel.FromXlsx(stream); + using XlsxWorkbook reader = Excel.FromXlsx(stream); Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetVisibilityAt(1)); @@ -83,7 +83,7 @@ public void Should_ReportEverySheetsState_When_ReadingXlsxFromMemory() { byte[] xlsx = BuildXlsx(null, "veryHidden"); - using XlsxReader reader = Excel.FromXlsx(xlsx.AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(xlsx.AsMemory()); Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.SheetVisibilityAt(1)); @@ -94,7 +94,7 @@ public void Should_TreatUnknownStatesAsVisible_When_ReadingXlsx() { byte[] xlsx = BuildXlsx("", "somethingElse", "VERYHIDDEN"); - using XlsxReader reader = Excel.FromXlsx(xlsx.AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(xlsx.AsMemory()); Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(1)); @@ -131,7 +131,7 @@ .. B.Record(BrtBundleSh, [.. B.U32(hsState), .. B.U32(0), .. B.WideString("rId1" byte[] rels = Encoding.UTF8.GetBytes( """"""); - var sheets = XlsbWorkbook.ParseSheets(workbook, rels); + var sheets = XlsbWorkbookParts.ParseSheets(workbook, rels); Assert.Equal(expected, Assert.Single(sheets).Visibility); } @@ -143,7 +143,7 @@ public void Should_DecodeHsState_When_ReadingXlsBoundSheet() sheetStates: [0, 1, 2], sheets: [("S1", [["A"]]), ("S2", [["B"]]), ("S3", [["C"]])]); - using XlsReader reader = Excel.FromXls(xls); + using XlsWorkbook reader = Excel.FromXls(xls); Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetVisibilityAt(1)); @@ -337,7 +337,7 @@ public void Should_Reject_When_TheCsvVisibilityIsNotADefinedValue() [Fact] public void Should_Reject_When_TheSheetIndexIsOutOfRange() { - using XlsxReader reader = Excel.FromXlsx(BuildXlsx([null]).AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx([null]).AsMemory()); Assert.Throws(() => reader.SheetVisibilityAt(-1)); Assert.Throws(() => reader.SheetVisibilityAt(1)); diff --git a/tests/ExcelReader.Tests/Reader/SourceParityTests.cs b/tests/ExcelReader.Tests/Reader/SourceParityTests.cs index db336e24..a33ab963 100644 --- a/tests/ExcelReader.Tests/Reader/SourceParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SourceParityTests.cs @@ -69,7 +69,7 @@ private string WriteTemp(byte[] bytes, string extension) return path; } - private XlsxReader OpenXlsx(Kind kind, byte[] bytes) + private XlsxWorkbook OpenXlsx(Kind kind, byte[] bytes) { switch (kind) { @@ -229,7 +229,7 @@ private static byte[] BuildXls() return buffer.ToArray(); } - private XlsReader OpenXls(Kind kind, byte[] bytes) + private XlsWorkbook OpenXls(Kind kind, byte[] bytes) { switch (kind) { @@ -319,7 +319,7 @@ private static byte[] BuildXlsb() return buffer.ToArray(); } - private XlsbReader OpenXlsb(Kind kind, byte[] bytes) + private XlsbWorkbook OpenXlsb(Kind kind, byte[] bytes) { switch (kind) { diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs index c4f31eaa..0e54fd88 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs @@ -26,7 +26,7 @@ private static byte[] BuildXls(int rows = 300) return buffer.ToArray(); } - private static List Drain(XlsReader.Enumerator e) + private static List Drain(XlsWorkbook.Enumerator e) { List values = []; while (e.MoveNext()) @@ -39,7 +39,7 @@ private static List Drain(XlsReader.Enumerator e) [Fact] public void GetEnumerator_After_Dispose_Throws() { - XlsReader reader = Excel.FromXls(BuildXls()); + XlsWorkbook reader = Excel.FromXls(BuildXls()); reader.Dispose(); Assert.Throws(() => reader.GetEnumerator()); Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); @@ -50,15 +50,15 @@ public void An_Enumerator_Outlives_Its_Reader() { byte[] bytes = BuildXls(); List expected; - using (XlsReader reference = Excel.FromXls(bytes)) + using (XlsWorkbook reference = Excel.FromXls(bytes)) { - using XlsReader.Enumerator all = reference.GetEnumerator(); + using XlsWorkbook.Enumerator all = reference.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); - XlsReader reader = Excel.FromXls(stream, leaveOpen: false); - XlsReader.Enumerator e = reader.GetEnumerator(); + XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); + XlsWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -75,8 +75,8 @@ public void An_Enumerator_Outlives_Its_Reader() public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXls(rows: 20)); - XlsReader reader = Excel.FromXls(stream, leaveOpen: false); - XlsReader.Enumerator e = reader.GetEnumerator(); + XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); + XlsWorkbook.Enumerator e = reader.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsReaderTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs similarity index 99% rename from tests/ExcelReader.Tests/Reader/Xls/XlsReaderTests.cs rename to tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs index 3ec96cfe..cced2be5 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs @@ -5,7 +5,7 @@ namespace ExcelReader.Tests.Reader.Xls { - public class XlsReaderTests + public class XlsWorkbookTests { private sealed class PersonRow { @@ -264,7 +264,7 @@ public void UnicodeCustomDateFormatMarksDateCells() } [Fact] - public void TypedParserUsesXlsReaderAndAttributeAliases() + public void TypedParserUsesXlsWorkbookAndAttributeAliases() { using var ms = XlsWorkbookBuilder.Build( sheets: [("S1", [["Name", "Age", "Active"], ["Ana", 31, false]])]); diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbEnumeratorTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbEnumeratorTests.cs index e1b51ebf..9738a5f7 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbEnumeratorTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbEnumeratorTests.cs @@ -7,13 +7,13 @@ namespace ExcelReader.Tests.Reader.Xlsb { public class XlsbEnumeratorTests { - private static XlsbReader BlankReader() + private static XlsbWorkbook BlankReader() { return new(sharedFlat: [], sharedOffsets: [0], styleIsDate: [], date1904: false); } - private static XlsbReader ReaderWithShared(params string[] strings) + private static XlsbWorkbook ReaderWithShared(params string[] strings) { byte[] flat = Encoding.UTF8.GetBytes(string.Concat(strings)); int[] offsets = new int[strings.Length + 1]; @@ -24,10 +24,10 @@ private static XlsbReader ReaderWithShared(params string[] strings) pos += Encoding.UTF8.GetByteCount(strings[i]); } offsets[strings.Length] = pos; - return new XlsbReader(flat, offsets, styleIsDate: [], date1904: false); + return new XlsbWorkbook(flat, offsets, styleIsDate: [], date1904: false); } - private static XlsbReader.Enumerator Open(XlsbReader reader, byte[] sheetBin, long entryLength = 0, CancellationToken? ct = null) + private static XlsbWorkbook.Enumerator Open(XlsbWorkbook reader, byte[] sheetBin, long entryLength = 0, CancellationToken? ct = null) { return new(reader, new MemoryStream(sheetBin), entryLength, ct ?? TestContext.Current.CancellationToken); } @@ -203,7 +203,7 @@ .. B.Record(Brt.CellBool, B.CellBool(1, 0, true)), [Fact] public void DateStyleMapsRealToDateType() { - var reader = new XlsbReader([], [0], styleIsDate: [false, true], date1904: false); + var reader = new XlsbWorkbook([], [0], styleIsDate: [false, true], date1904: false); byte[] sheet = [ .. B.Record(Brt.RowHdr), diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs index f409132a..7973038e 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs @@ -26,7 +26,7 @@ private static byte[] BuildXlsb(int rows = 300) return buffer.ToArray(); } - private static List Drain(XlsbReader.Enumerator e) + private static List Drain(XlsbWorkbook.Enumerator e) { List values = []; while (e.MoveNext()) @@ -39,7 +39,7 @@ private static List Drain(XlsbReader.Enumerator e) [Fact] public void GetEnumerator_After_Dispose_Throws() { - XlsbReader reader = Excel.FromXlsb(BuildXlsb()); + XlsbWorkbook reader = Excel.FromXlsb(BuildXlsb()); reader.Dispose(); Assert.Throws(() => reader.GetEnumerator()); Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); @@ -50,15 +50,15 @@ public void An_Enumerator_Outlives_Its_Reader() { byte[] bytes = BuildXlsb(); List expected; - using (XlsbReader reference = Excel.FromXlsb(bytes)) + using (XlsbWorkbook reference = Excel.FromXlsb(bytes)) { - using XlsbReader.Enumerator all = reference.GetEnumerator(); + using XlsbWorkbook.Enumerator all = reference.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); - XlsbReader reader = Excel.FromXlsb(stream, leaveOpen: false); - XlsbReader.Enumerator e = reader.GetEnumerator(); + XlsbWorkbook reader = Excel.FromXlsb(stream, leaveOpen: false); + XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -75,8 +75,8 @@ public void An_Enumerator_Outlives_Its_Reader() public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXlsb(rows: 20)); - XlsbReader reader = Excel.FromXlsb(stream, leaveOpen: false); - XlsbReader.Enumerator e = reader.GetEnumerator(); + XlsbWorkbook reader = Excel.FromXlsb(stream, leaveOpen: false); + XlsbWorkbook.Enumerator e = reader.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbPartsTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbPartsTests.cs index 87b113ec..b1e1cf8b 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbPartsTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbPartsTests.cs @@ -71,7 +71,7 @@ public void ParsesSheetsWithRelTargets() byte[] rels = Encoding.UTF8.GetBytes( """"""); - var sheets = XlsbWorkbook.ParseSheets(workbook, rels); + var sheets = XlsbWorkbookParts.ParseSheets(workbook, rels); Assert.Equal(2, sheets.Length); Assert.Equal(("Plan1", "xl/worksheets/sheet1.bin", ExcelSheetVisibility.Visible), sheets[0]); @@ -81,9 +81,9 @@ public void ParsesSheetsWithRelTargets() [Fact] public void ParsesDate1904Flag() { - Assert.True(XlsbWorkbook.ParseDate1904(WorkbookBin(0x01))); - Assert.False(XlsbWorkbook.ParseDate1904(WorkbookBin(0x00))); - Assert.False(XlsbWorkbook.ParseDate1904([])); + Assert.True(XlsbWorkbookParts.ParseDate1904(WorkbookBin(0x01))); + Assert.False(XlsbWorkbookParts.ParseDate1904(WorkbookBin(0x00))); + Assert.False(XlsbWorkbookParts.ParseDate1904([])); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs index 71a1997c..01882b5b 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs @@ -93,7 +93,7 @@ public async Task RowCrossingColumnBlocksGetsOneSpanPerBlock() Assert.Equal([(0, 1023), (1024, 2047)], structure.Rows[0].Spans); Assert.Equal([(0, 0)], structure.Rows[1].Spans); AssertRowsCovered(structure); - using XlsbReader reader = Excel.FromXlsb(new MemoryStream(bytes)); + using XlsbWorkbook reader = Excel.FromXlsb(new MemoryStream(bytes)); var values = new List(); foreach (var r in reader) { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs index 85ac37c0..4d3a0267 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs @@ -89,12 +89,12 @@ public void MoveToSheetAndTryMoveToSheetWorkOnTheMemoryPath() ]); byte[] bytes = built.ToArray(); - using XlsxReader reader = Excel.FromXlsx(bytes.AsMemory()); + using XlsxWorkbook reader = Excel.FromXlsx(bytes.AsMemory()); Assert.Equal(2, reader.SheetCount); reader.MoveToSheet(1); Assert.Equal("Second", reader.SheetName); - using (XlsxReader.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("2", e.Current[0].GetString()); @@ -102,7 +102,7 @@ public void MoveToSheetAndTryMoveToSheetWorkOnTheMemoryPath() Assert.True(reader.TryMoveToSheet("First")); Assert.Equal("First", reader.SheetName); - using (XlsxReader.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); @@ -119,8 +119,8 @@ public void ReadsCorrectlyOverANonArrayBackedMemory() byte[] bytes = built.ToArray(); var manager = new NonArrayMemoryManager(bytes); - using XlsxReader reader = Excel.FromXlsx(manager.Memory); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(manager.Memory); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("42", e.Current[0].GetString()); } @@ -132,7 +132,7 @@ public void OpenMemoryDetectsXlsx() using MemoryStream built = WorkbookBuilder.Build("""1"""); byte[] bytes = built.ToArray(); using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); - Assert.IsType(reader); + Assert.IsType(reader); } [Fact] @@ -141,7 +141,7 @@ public async Task OpenMemoryDetectsXlsb() CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = await BuildXlsbAsync(ct); using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); - Assert.IsType(reader); + Assert.IsType(reader); } [Fact] @@ -150,7 +150,7 @@ public void OpenMemoryDetectsXls() using MemoryStream built = XlsWorkbookBuilder.Build(sheets: [("S1", [["Name", 1, true]])]); byte[] bytes = built.ToArray(); using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); - Assert.IsType(reader); + Assert.IsType(reader); } [Fact] @@ -205,11 +205,11 @@ public void ForgedOversizedEntryThrowsSameLimitOnBothPaths() ExcelLimitExceededException streamedEx = Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(new MemoryStream(bytes), options: options); + using XlsxWorkbook reader = Excel.FromXlsx(new MemoryStream(bytes), options: options); }); ExcelLimitExceededException memoryEx = Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(bytes.AsMemory(), options); + using XlsxWorkbook reader = Excel.FromXlsx(bytes.AsMemory(), options); }); Assert.Equal(streamedEx.LimitName, memoryEx.LimitName); } @@ -223,13 +223,13 @@ public void MissingWorksheetPartThrowsInvalidDataExceptionOnBothPaths() Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(new MemoryStream(withoutSheet)); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(new MemoryStream(withoutSheet)); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); }); Assert.Throws(() => { - using XlsxReader reader = Excel.FromXlsx(withoutSheet.AsMemory()); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(withoutSheet.AsMemory()); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); }); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs index 6757919b..e59b05d2 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs @@ -11,8 +11,8 @@ public void PrefixedNumberAndInlineStringCellsAreRead() { using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """42hello"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -27,8 +27,8 @@ public void PrefixedMultipleRowsAreAllEnumerated() { using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """123"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); @@ -45,8 +45,8 @@ public void PrefixedSharedStringsAreResolved() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """01""", sharedStrings: "alphabeta"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("alpha", e.Current[0].GetString()); @@ -59,8 +59,8 @@ public void PrefixedPhoneticRunsAreSkippedInSharedStrings() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """0""", sharedStrings: "株式会社カブシキガイシャ"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("株式会社", e.Current[0].GetString()); @@ -72,8 +72,8 @@ public void PrefixedCustomDateStyleIsClassifiedAsDate() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """45658""", stylesInner: """"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -87,8 +87,8 @@ public async Task PrefixedWorksheetIsReadAsync() await using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """70""", sharedStrings: "async"); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal("7", e.Current[0].GetString()); @@ -101,8 +101,8 @@ public void UnusualPrefixNameIsSupported() { using MemoryStream ms = WorkbookBuilder.BuildPrefixed("ss", """99"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("99", e.Current[0].GetString()); @@ -114,8 +114,8 @@ public void IsoDateTimeCellIsParsedAsDate() { using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02T13:45:00"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -128,8 +128,8 @@ public void IsoDateOnlyCellIsParsedAsDate() { using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -142,8 +142,8 @@ public void UnparseableIsoDateCellIsKeptAsString() { using MemoryStream ms = WorkbookBuilder.Build( """not-a-date"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.ExcelString, e.Current[0].Type); @@ -158,8 +158,8 @@ public void IsoDateBelowOaDateRangeIsKeptAsString(string text) { using MemoryStream ms = WorkbookBuilder.Build( $"""{text}"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.ExcelString, e.Current[0].Type); @@ -171,8 +171,8 @@ public void IsoDateAtOaDateFloorIsStillParsedAsDate() { using MemoryStream ms = WorkbookBuilder.Build( """0100-01-01"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -183,8 +183,8 @@ public async Task IsoDateCellIsParsedAsDateAsync() { await using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02T00:00:00"""); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -197,8 +197,8 @@ public void IsoDateCellCombinedWithPrefix() { using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """2026-03-04"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs index 47239380..5ac1cf2e 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs @@ -153,10 +153,10 @@ private static async Task CaptureAsyncExceptionTypeAsync(byte[] bytes, boo { var options = new ExcelReaderOptions { PrefetchDecompression = prefetch }; await using MemoryStream stream = new(bytes, writable: false); - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, options: options, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: ct); Exception ex = await Assert.ThrowsAsync(async () => { - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(ct); await DrainRowsAsync(e); }); return ex.GetType(); @@ -173,7 +173,7 @@ private static void DrainRows(IExcelRowEnumerator e) } } - private static async Task DrainRowsAsync(XlsxReader.Enumerator e) + private static async Task DrainRowsAsync(XlsxWorkbook.Enumerator e) { while (await e.MoveNextAsync()) { @@ -197,10 +197,10 @@ public void PrefetchOnStillTripsTotalDecompressedLimitSync() PrefetchDecompression = true, }; - using XlsxReader reader = Excel.FromXlsx(ms, options: options); + using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); DrainRows(e); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -219,10 +219,10 @@ public async Task PrefetchOnStillTripsTotalDecompressedLimitAsync() PrefetchDecompression = true, }; - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, options: options, ct: ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, options: options, ct: ct); ExcelLimitExceededException ex = await Assert.ThrowsAsync(async () => { - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(ct); await DrainRowsAsync(e); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -239,9 +239,9 @@ public async Task AlreadyCancelledTokenThrowsWithPrefetchOn() cts.Cancel(); await using MemoryStream stream = new(bytes, writable: false); - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); await Assert.ThrowsAnyAsync(async () => await e.MoveNextAsync()); } @@ -255,8 +255,8 @@ public async Task CancellingMidEnumerationStopsPromptlyWithPrefetchOn() using CancellationTokenSource cts = new(); await using MemoryStream stream = new(bytes, writable: false); - await using XlsxReader reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); @@ -402,12 +402,12 @@ private static async Task BuildLargeXlsbAsync() } - private static XlsxReader OpenXlsx(Stream stream, ExcelReaderOptions options) + private static XlsxWorkbook OpenXlsx(Stream stream, ExcelReaderOptions options) { return Excel.FromXlsx(stream, options: options); } - private static XlsbReader OpenXlsb(Stream stream, ExcelReaderOptions options) + private static XlsbWorkbook OpenXlsb(Stream stream, ExcelReaderOptions options) { return Excel.FromXlsb(stream, options: options); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs index 5d564bca..dce20b82 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs @@ -10,8 +10,8 @@ public class RealWorldXlsxCorpusTests public void ReadsSheetJsGeneratedWorkbook() { string path = Path.Combine(AppContext.BaseDirectory, "data", "sheetjs-sample.xlsx"); - using XlsxReader reader = Excel.FromXlsxFile(path); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsxFile(path); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("name", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs index f3a4594c..9f0ca09f 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs @@ -123,7 +123,7 @@ private static IEnumerable LibraryAndCloudFixtures() public void SyncReaderHandlesCorpusFixture(CorpusFixture fixture) { using MemoryStream ms = WorkbookBuilder.Build(fixture.Rows, fixture.SharedStrings); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.NotEmpty(fixture.Expected); AssertExpected(reader.GetEnumerator(), fixture.Expected); @@ -135,14 +135,14 @@ public async Task AsyncReaderHandlesCorpusFixture(CorpusFixture fixture) { CancellationToken ct = TestContext.Current.CancellationToken; await using MemoryStream ms = WorkbookBuilder.Build(fixture.Rows, fixture.SharedStrings); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: ct); - await using XlsxReader.Enumerator rows = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); + await using XlsxWorkbook.Enumerator rows = reader.GetAsyncEnumerator(ct); Assert.NotEmpty(fixture.Expected); await AssertExpectedAsync(rows, fixture.Expected); } - private static void AssertExpected(XlsxReader.Enumerator rows, ExpectedCell[] expected) + private static void AssertExpected(XlsxWorkbook.Enumerator rows, ExpectedCell[] expected) { int next = 0; int rowIndex = 0; @@ -156,7 +156,7 @@ private static void AssertExpected(XlsxReader.Enumerator rows, ExpectedCell[] ex } private static async Task AssertExpectedAsync( - XlsxReader.Enumerator rows, + XlsxWorkbook.Enumerator rows, ExpectedCell[] expected) { int next = 0; diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs index d6e0ec4b..6de541b1 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs @@ -26,7 +26,7 @@ private static byte[] BuildXlsx(int rows = 300) return buffer.ToArray(); } - private static List Drain(XlsxReader.Enumerator e) + private static List Drain(XlsxWorkbook.Enumerator e) { List values = []; while (e.MoveNext()) @@ -39,7 +39,7 @@ private static List Drain(XlsxReader.Enumerator e) [Fact] public void A_Failed_Shared_String_Load_Fails_Every_Later_Enumeration() { - using XlsxReader reader = Excel.FromXlsx(BuildXlsx(), new ExcelReaderOptions { MaxSharedStringBytes = 16 }); + using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx(), new ExcelReaderOptions { MaxSharedStringBytes = 16 }); Assert.Throws(() => reader.GetEnumerator()); Assert.Throws(() => reader.GetEnumerator()); } @@ -47,11 +47,11 @@ public void A_Failed_Shared_String_Load_Fails_Every_Later_Enumeration() [Fact] public async Task A_Failed_Shared_String_Load_Fails_Every_Later_Async_Enumeration() { - using XlsxReader reader = Excel.FromXlsx( + using XlsxWorkbook reader = Excel.FromXlsx( new MemoryStream(BuildXlsx(), writable: false), leaveOpen: false, new ExcelReaderOptions { MaxSharedStringBytes = 16 }); for (int attempt = 0; attempt < 2; attempt++) { - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => await e.MoveNextAsync()); } } @@ -59,7 +59,7 @@ public async Task A_Failed_Shared_String_Load_Fails_Every_Later_Async_Enumeratio [Fact] public void GetEnumerator_After_Dispose_Throws() { - XlsxReader reader = Excel.FromXlsx(BuildXlsx()); + XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx()); reader.Dispose(); Assert.Throws(() => reader.GetEnumerator()); Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); @@ -70,15 +70,15 @@ public void An_Enumerator_Outlives_Its_Reader() { byte[] bytes = BuildXlsx(); List expected; - using (XlsxReader reference = Excel.FromXlsx(bytes)) + using (XlsxWorkbook reference = Excel.FromXlsx(bytes)) { - using XlsxReader.Enumerator all = reference.GetEnumerator(); + using XlsxWorkbook.Enumerator all = reference.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxReader.Enumerator e = reader.GetEnumerator(); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); + XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -95,8 +95,8 @@ public void An_Enumerator_Outlives_Its_Reader() public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXlsx(rows: 20)); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxReader.Enumerator e = reader.GetEnumerator(); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); + XlsxWorkbook.Enumerator e = reader.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); @@ -109,7 +109,7 @@ public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() public void A_Failed_GetEnumerator_Does_Not_Keep_The_Reader_Alive() { TrickleStream stream = new(BuildXlsx(rows: 20)); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxSharedStringBytes = 16 }); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxSharedStringBytes = 16 }); Assert.Throws(() => reader.GetEnumerator()); reader.Dispose(); Assert.False(stream.CanRead); @@ -119,8 +119,8 @@ public void A_Failed_GetEnumerator_Does_Not_Keep_The_Reader_Alive() public async Task A_Disposed_Deferred_Enumerator_Does_Not_Reopen_Its_Sheet() { TrickleStream stream = new(BuildXlsx(rows: 20)); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); + XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); await e.DisposeAsync(); Task move = Task.Run(async () => await e.MoveNextAsync(), TestContext.Current.CancellationToken); Assert.Same(move, await Task.WhenAny(move, Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken))); @@ -134,9 +134,9 @@ public async Task A_Cancelled_Shared_String_Load_Is_Undone_And_Loads_Again() { byte[] bytes = BuildXlsx(); List expected; - using (XlsxReader reference = Excel.FromXlsx(bytes)) + using (XlsxWorkbook reference = Excel.FromXlsx(bytes)) { - using XlsxReader.Enumerator all = reference.GetEnumerator(); + using XlsxWorkbook.Enumerator all = reference.GetEnumerator(); expected = Drain(all); } @@ -149,7 +149,7 @@ public async Task A_Cancelled_Shared_String_Load_Is_Undone_And_Loads_Again() int reads = 0; long cancelledAt = -1; TrickleStream stream = new(bytes); - XlsxReader reader = Excel.FromXlsx(stream, leaveOpen: false); + XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); stream.OnRead = () => { if (++reads == 20) @@ -159,14 +159,14 @@ public async Task A_Cancelled_Shared_String_Load_Is_Undone_And_Loads_Again() } }; - await using (XlsxReader.Enumerator cancelled = reader.GetAsyncEnumerator(cts.Token)) + await using (XlsxWorkbook.Enumerator cancelled = reader.GetAsyncEnumerator(cts.Token)) { await Assert.ThrowsAnyAsync(async () => await cancelled.MoveNextAsync()); } Assert.InRange(cancelledAt, bodyStart, bodyEnd - 1); List actual = []; - await using (XlsxReader.Enumerator again = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)) + await using (XlsxWorkbook.Enumerator again = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)) { while (await again.MoveNextAsync()) { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs index da6fbbbd..08ddd532 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs @@ -151,10 +151,10 @@ private static IEnumerable CloudAndJavaProducerFixtures() public async Task ReaderHandlesProducerShapedXlsxFixtures(ProducerFixture fixture) { await using MemoryStream workbook = BuildProducerFixture(fixture); - await using XlsxReader reader = await Excel.FromXlsxAsync( + await using XlsxWorkbook reader = await Excel.FromXlsxAsync( workbook, ct: TestContext.Current.CancellationToken); - await using XlsxReader.Enumerator rows = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator rows = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(rows.MoveNext()); foreach (ExpectedCell expected in fixture.Expected) diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxReaderTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs similarity index 80% rename from tests/ExcelReader.Tests/Reader/Xlsx/XlsxReaderTests.cs rename to tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs index cbfc5d85..e1f36ac8 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs @@ -4,7 +4,7 @@ namespace ExcelReader.Tests.Reader.Xlsx { - public class XlsxReaderTests + public class XlsxWorkbookTests { [Fact] public void MultiSheetSelectionByIndexReadsDistinctData() @@ -15,13 +15,13 @@ public void MultiSheetSelectionByIndexReadsDistinctData() ("Second", """2"""), ("Third", """3"""), ]); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.Equal(3, reader.SheetCount); for (int i = 0; i < reader.SheetCount; i++) { reader.MoveToSheet(i); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal((i + 1).ToString(CultureInfo.InvariantCulture), e.Current[0].GetString()); } @@ -35,11 +35,11 @@ public void MultiSheetSelectionByNameIsCaseInsensitive() ("Alpha", """10"""), ("Beta", """20"""), ]); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.True(reader.TryMoveToSheet("beta")); Assert.Equal("Beta", reader.SheetName); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("20", e.Current[0].GetString()); } @@ -49,9 +49,9 @@ public void SameSheetCanBeEnumeratedMoreThanOnce() { using MemoryStream ms = WorkbookBuilder.Build( """12"""); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); - using (XlsxReader.Enumerator first = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator first = reader.GetEnumerator()) { Assert.True(first.MoveNext()); Assert.Equal("1", first.Current[0].GetString()); @@ -60,7 +60,7 @@ public void SameSheetCanBeEnumeratedMoreThanOnce() Assert.False(first.MoveNext()); } - using XlsxReader.Enumerator second = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator second = reader.GetEnumerator(); Assert.True(second.MoveNext()); Assert.Equal("1", second.Current[0].GetString()); } @@ -73,23 +73,23 @@ public void SwitchingAwayAndBackToASheetReenumeratesFromStart() ("A", """11"""), ("B", """22"""), ]); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); - using (XlsxReader.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("11", e.Current[0].GetString()); } reader.MoveToSheet(1); - using (XlsxReader.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("22", e.Current[0].GetString()); } reader.MoveToSheet(0); - using XlsxReader.Enumerator back = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator back = reader.GetEnumerator(); Assert.True(back.MoveNext()); Assert.Equal("11", back.Current[0].GetString()); } @@ -98,9 +98,9 @@ public void SwitchingAwayAndBackToASheetReenumeratesFromStart() public void EmptySheetYieldsNoRows() { using MemoryStream ms = WorkbookBuilder.Build(sheetRows: ""); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.False(e.MoveNext()); } @@ -121,8 +121,8 @@ public void HugeSharedStringTableResolvesEveryIndexIncludingRepeatsViaCache() } using MemoryStream ms = WorkbookBuilder.Build(rows.ToString(), sharedStrings: sharedStrings.ToString()); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); string? firstRepeat = null; for (int i = 0; i < 200; i++) @@ -141,8 +141,8 @@ public void HugeSharedStringTableResolvesEveryIndexIncludingRepeatsViaCache() public void NumericCellIsTypedAsNumber() { using MemoryStream ms = WorkbookBuilder.Build("""3.14"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -156,8 +156,8 @@ public void DateStyledNumericCellIsTypedAsDate() using MemoryStream ms = WorkbookBuilder.Build( """45658""", styles: ""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -169,7 +169,7 @@ public void DateStyledNumericCellIsTypedAsDate() public void IsDate1904DefaultsToFalse() { using MemoryStream ms = WorkbookBuilder.Build("""1"""); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.False(reader.IsDate1904); } @@ -179,7 +179,7 @@ public void IsDate1904IsTrueWhenWorkbookPrDeclaresIt() { using MemoryStream ms = WorkbookBuilder.Build( """1""", date1904: true); - using XlsxReader reader = Excel.FromXlsx(ms); + using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.True(reader.IsDate1904); } @@ -189,8 +189,8 @@ public void GetStringPreservesOriginalNumericTextRatherThanReformattingCachedDou { using MemoryStream ms = WorkbookBuilder.Build( """1.50"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDouble(out double d)); @@ -208,8 +208,8 @@ public void TruncatedCellXfsIsNotFatal(string styles) """1""", styles: styles); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs index d0b657d5..af1a6846 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs @@ -11,8 +11,8 @@ public void SingleQuotedCellAttributesAreRead() using MemoryStream ms = WorkbookBuilder.Build( """0""", sharedStrings: "single quoted"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("single quoted", e.Current[2].GetString()); @@ -31,8 +31,8 @@ public void CellTagAttributeLayoutsResolveTheSameCell(string cells) using MemoryStream ms = WorkbookBuilder.Build( $"""{cells}""", sharedStrings: "hit"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Cell cell = e.Current[27]; @@ -52,8 +52,8 @@ public void SharedStringIndexFormsResolveLikeTheGenericParser(string index, stri using MemoryStream ms = WorkbookBuilder.Build( $"""{index}7""", sharedStrings: "firstsecond"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); @@ -75,8 +75,8 @@ public void PlainAndMarkedUpSharedStringsInterleaveCorrectly() + "baseruby" + "" + "last"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); for (int i = 0; i < expected.Length; i++) @@ -90,8 +90,8 @@ public void CommentContainingGreaterThanInsideSheetDataIsSkipped() { using MemoryStream ms = WorkbookBuilder.Build( """12"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); @@ -105,8 +105,8 @@ public async Task AsyncCommentContainingGreaterThanInsideSheetDataIsSkipped() { await using MemoryStream ms = WorkbookBuilder.Build( """12"""); - await using XlsxReader reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal("1", e.Current[0].GetString()); @@ -120,8 +120,8 @@ public void CDataInsideTextRunIsCopiedWithoutEntityDecoding() { using MemoryStream ms = WorkbookBuilder.Build( """]]>"""); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("raw & ", e.Current[0].GetString()); @@ -133,8 +133,8 @@ public void CDataInsideSharedStringIsCopiedWithoutEntityDecoding() using MemoryStream ms = WorkbookBuilder.Build( """0""", sharedStrings: "]]>"); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("shared & ", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs index d58f9b5f..99d52279 100644 --- a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs @@ -248,7 +248,7 @@ public async Task RecordWriterWritesHeaderOnlyForEmptyRecords() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.False(e.MoveNext()); @@ -298,7 +298,7 @@ public async Task RecordWriterSkipsIgnoredProperty() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -607,7 +607,7 @@ public async Task NullableAndGenericNumericOverloadsRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1234567890123", e.Current[0].GetString()); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -996,7 +996,7 @@ await genericSheet.WriteRecordsAsync( }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); for (int expected = 1; expected <= 4; expected++) { Assert.True(e.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs b/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs index 67054149..c8608a6d 100644 --- a/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs @@ -20,8 +20,8 @@ public async Task XlsxPrefetchWriteReadsBackIdenticalCells() byte[] bytes = await BuildXlsxAsync(prefetchWrite: true, ct); using MemoryStream ms = new(bytes, writable: false); - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -40,8 +40,8 @@ public async Task XlsbPrefetchWriteReadsBackIdenticalCells() byte[] bytes = await BuildXlsbAsync(prefetchWrite: true, ct); using MemoryStream ms = new(bytes, writable: false); - using XlsbReader reader = Excel.FromXlsb(ms); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook reader = Excel.FromXlsb(ms); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -72,8 +72,8 @@ public async Task XlsxPrefetchWriteWorksThroughSyncRowApi() } ms.Position = 0; - using XlsxReader reader = Excel.FromXlsx(ms); - using XlsxReader.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook reader = Excel.FromXlsx(ms); + using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -102,8 +102,8 @@ public async Task XlsbPrefetchWriteRoundTripsASmallSheetThatNeverSpills() } ms.Position = 0; - using XlsbReader reader = Excel.FromXlsb(ms); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook reader = Excel.FromXlsb(ms); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { diff --git a/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs b/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs index 13f9e149..c53ac8f3 100644 --- a/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs @@ -89,8 +89,8 @@ public async Task XlsxWriterOptInSharedStringsDeduplicatesAndRoundTrips() } workbook.Position = 0; - await using XlsxReader reader = Excel.FromXlsx(workbook); - using XlsxReader.Enumerator rows = reader.GetEnumerator(); + await using XlsxWorkbook reader = Excel.FromXlsx(workbook); + using XlsxWorkbook.Enumerator rows = reader.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal("repeat", rows.Current[0].GetString()); Assert.Equal("repeat", rows.Current[1].GetString()); @@ -98,7 +98,7 @@ public async Task XlsxWriterOptInSharedStringsDeduplicatesAndRoundTrips() Assert.False(rows.MoveNext()); reader.MoveToSheet(1); - using XlsxReader.Enumerator secondRows = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator secondRows = reader.GetEnumerator(); Assert.True(secondRows.MoveNext()); Assert.Equal("repeat", secondRows.Current[0].GetString()); } @@ -201,8 +201,8 @@ public async Task XlsbWriterOptInSharedStringsDeduplicatesAndRoundTrips() } workbook.Position = 0; - await using XlsbReader reader = Excel.FromXlsb(workbook); - using XlsbReader.Enumerator rows = reader.GetEnumerator(); + await using XlsbWorkbook reader = Excel.FromXlsb(workbook); + using XlsbWorkbook.Enumerator rows = reader.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal(CellType.ExcelString, rows.Current[0].Type); Assert.Equal("repeat", rows.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs index 8580c73d..f6ada623 100644 --- a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs @@ -80,7 +80,7 @@ public async Task OpenDetectsWrittenXlsb() }); await using var reader = Excel.Open(ms); - Assert.IsType(reader); + Assert.IsType(reader); } [Fact] @@ -127,7 +127,7 @@ public async Task Date1904RoundTrips() await using var reader = Excel.FromXlsb(ms); Assert.True(reader.IsDate1904); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); Assert.Equal(date, parsed); @@ -155,7 +155,7 @@ public async Task NumericOverloadsRoundTrip() }); await using var reader = Excel.FromXlsb(ms); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryParse(null, out int intValue)); Assert.Equal(123, intValue); @@ -201,7 +201,7 @@ public async Task NullableAndGenericOverloadsRoundTrip() }); await using var reader = Excel.FromXlsb(ms); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -236,7 +236,7 @@ public async Task NegativeSkipThrows() }); await using var reader = Excel.FromXlsb(ms); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("ok", e.Current[0].GetString()); Assert.False(e.MoveNext()); @@ -322,7 +322,7 @@ public async Task BulkXlsbCellDate1904RoundTrips() await using var reader = Excel.FromXlsb(ms); Assert.True(reader.IsDate1904); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); Assert.Equal(date, parsed); @@ -460,8 +460,8 @@ public async Task NegativeZeroKeepsRealCellSoItsSignSurvives() (int Id, int PayloadLength)[] cells = ReadNumericCellRecords(ms); ms.Position = 0; - await using XlsbReader reader = Excel.FromXlsb(ms, leaveOpen: true); - using XlsbReader.Enumerator e = reader.GetEnumerator(); + await using XlsbWorkbook reader = Excel.FromXlsb(ms, leaveOpen: true); + using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDouble(out double readBack)); return (cells, readBack); From b1807c073fd4ce8b945c32127e67f5cd67471164 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 15:29:58 -0300 Subject: [PATCH 02/49] feat(core): add workbook and sheet types next to the reader surface Add IExcelWorkbook, IExcelSheet, ExcelSheetList and the XLSX, XLSB, XLS and CSV sheet structs, with Sheets, FirstSheet and TryGetSheet on each workbook. The v5 reader interfaces inherit the new ones so callers can move over file by file; nothing is removed yet. Co-Authored-By: Claude Sonnet 5.5 --- .../PublicAPI/PublicAPI.Unshipped.txt | 91 +++++ src/ExcelReader.Core/Reader/Csv/CsvReader.cs | 49 ++- src/ExcelReader.Core/Reader/Csv/CsvSheet.cs | 52 +++ src/ExcelReader.Core/Reader/ExcelSheetList.cs | 48 +++ .../Reader/IExcelRowReader.cs | 20 +- src/ExcelReader.Core/Reader/IExcelWorkbook.cs | 84 +++++ .../Reader/Internal/ReaderLifetime.cs | 5 + src/ExcelReader.Core/Reader/Xls/XlsSheet.cs | 53 +++ .../Reader/Xls/XlsWorkbook.cs | 95 ++++- src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs | 53 +++ .../Reader/Xlsb/XlsbWorkbook.cs | 98 +++++- src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs | 53 +++ .../Reader/Xlsx/XlsxWorkbook.cs | 95 ++++- .../Native/NativeApiTests.Reading.cs | 14 + .../Reader/WorkbookSheetTests.cs | 327 ++++++++++++++++++ 15 files changed, 1081 insertions(+), 56 deletions(-) create mode 100644 src/ExcelReader.Core/Reader/Csv/CsvSheet.cs create mode 100644 src/ExcelReader.Core/Reader/ExcelSheetList.cs create mode 100644 src/ExcelReader.Core/Reader/IExcelWorkbook.cs create mode 100644 src/ExcelReader.Core/Reader/Xls/XlsSheet.cs create mode 100644 src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs create mode 100644 src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs create mode 100644 tests/ExcelReader.Tests/Reader/WorkbookSheetTests.cs diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index fcd93eb7..fc467397 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -73,6 +73,46 @@ ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Reader.Csv.CsvReader.FirstSheet.get -> ExcelReader.Core.Reader.Csv.CsvSheet +ExcelReader.Core.Reader.Csv.CsvReader.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Csv.CsvReader.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> bool +ExcelReader.Core.Reader.Csv.CsvSheet +ExcelReader.Core.Reader.Csv.CsvSheet.CsvSheet() -> void +ExcelReader.Core.Reader.Csv.CsvSheet.Equals(ExcelReader.Core.Reader.Csv.CsvSheet other) -> bool +ExcelReader.Core.Reader.Csv.CsvSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +ExcelReader.Core.Reader.Csv.CsvSheet.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +ExcelReader.Core.Reader.Csv.CsvSheet.Index.get -> int +ExcelReader.Core.Reader.Csv.CsvSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Csv.CsvSheet.Name.get -> string! +ExcelReader.Core.Reader.Csv.CsvSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.ExcelSheetList +ExcelReader.Core.Reader.ExcelSheetList.Count.get -> int +ExcelReader.Core.Reader.ExcelSheetList.GetEnumerator() -> System.ArraySegment.Enumerator +ExcelReader.Core.Reader.ExcelSheetList.this[int index].get -> TSheet +ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelSheet +ExcelReader.Core.Reader.IExcelSheet.Index.get -> int +ExcelReader.Core.Reader.IExcelSheet.Name.get -> string! +ExcelReader.Core.Reader.IExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.IExcelSheet +ExcelReader.Core.Reader.IExcelSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator +ExcelReader.Core.Reader.IExcelSheet.GetEnumerator() -> TEnumerator +ExcelReader.Core.Reader.IExcelSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelWorkbook +ExcelReader.Core.Reader.IExcelWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.IExcelSheet! +ExcelReader.Core.Reader.IExcelWorkbook.IsDate1904.get -> bool +ExcelReader.Core.Reader.IExcelWorkbook.SheetAt(int index) -> ExcelReader.Core.Reader.IExcelSheet! +ExcelReader.Core.Reader.IExcelWorkbook.SheetCount.get -> int +ExcelReader.Core.Reader.IExcelWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.IExcelSheet! sheet) -> bool +ExcelReader.Core.Reader.Xls.XlsSheet +ExcelReader.Core.Reader.Xls.XlsSheet.Equals(ExcelReader.Core.Reader.Xls.XlsSheet other) -> bool +ExcelReader.Core.Reader.Xls.XlsSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! +ExcelReader.Core.Reader.Xls.XlsSheet.Index.get -> int +ExcelReader.Core.Reader.Xls.XlsSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xls.XlsSheet.Name.get -> string! +ExcelReader.Core.Reader.Xls.XlsSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xls.XlsSheet.XlsSheet() -> void ExcelReader.Core.Reader.Xls.XlsWorkbook ExcelReader.Core.Reader.Xls.XlsWorkbook.Dispose() -> void ExcelReader.Core.Reader.Xls.XlsWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -82,6 +122,7 @@ ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.Dispose() -> void ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xls.XlsWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xls.XlsSheet ExcelReader.Core.Reader.Xls.XlsWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! ExcelReader.Core.Reader.Xls.XlsWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! ExcelReader.Core.Reader.Xls.XlsWorkbook.IsDate1904.get -> bool @@ -91,7 +132,18 @@ ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetName.get -> string! ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetNameAt(int index) -> string! ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xls.XlsWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xls.XlsWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> bool ExcelReader.Core.Reader.Xls.XlsWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(ExcelReader.Core.Reader.Xlsb.XlsbSheet other) -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Index.get -> int +ExcelReader.Core.Reader.Xlsb.XlsbSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Name.get -> string! +ExcelReader.Core.Reader.Xlsb.XlsbSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsb.XlsbSheet.XlsbSheet() -> void ExcelReader.Core.Reader.Xlsb.XlsbWorkbook ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Dispose() -> void ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -99,6 +151,7 @@ ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsb.XlsbSheet ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.IsDate1904.get -> bool @@ -108,7 +161,18 @@ ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetName.get -> string! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetNameAt(int index) -> string! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> bool ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(ExcelReader.Core.Reader.Xlsx.XlsxSheet other) -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Index.get -> int +ExcelReader.Core.Reader.Xlsx.XlsxSheet.IsDate1904.get -> bool +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Name.get -> string! +ExcelReader.Core.Reader.Xlsx.XlsxSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsx.XlsxSheet.XlsxSheet() -> void ExcelReader.Core.Reader.Xlsx.XlsxWorkbook ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Dispose() -> void ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -116,6 +180,7 @@ ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.Current.get -> ExcelReader.Core.Reader.Row ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsx.XlsxSheet ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.IsDate1904.get -> bool @@ -125,7 +190,15 @@ ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetName.get -> string! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetNameAt(int index) -> string! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! +ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> bool ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool +override ExcelReader.Core.Reader.Csv.CsvSheet.GetHashCode() -> int +override ExcelReader.Core.Reader.Xls.XlsSheet.GetHashCode() -> int +override ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetHashCode() -> int +override ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetHashCode() -> int +static ExcelReader.Core.Reader.Csv.CsvSheet.operator !=(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool +static ExcelReader.Core.Reader.Csv.CsvSheet.operator ==(ExcelReader.Core.Reader.Csv.CsvSheet left, ExcelReader.Core.Reader.Csv.CsvSheet right) -> bool static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsWorkbook! static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -141,3 +214,21 @@ static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Xls.XlsSheet.operator !=(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool +static ExcelReader.Core.Reader.Xls.XlsSheet.operator ==(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool +static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator !=(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool +static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator ==(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool +static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator !=(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool +static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator ==(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool +~override ExcelReader.Core.Reader.Csv.CsvSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Csv.CsvSheet.ToString() -> string +~override ExcelReader.Core.Reader.Xls.XlsSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xls.XlsSheet.ToString() -> string +~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.ToString() -> string +~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(object obj) -> bool +~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.ToString() -> string +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs index ae5277c4..42c16d32 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs @@ -25,6 +25,7 @@ public sealed partial class CsvReader : IExcelRowReader, IExcelRowReader _memory; + private readonly ExcelSheetList _sheetList; private int _enumeratedOnce; private SafeFileHandle? _chunkHandle; @@ -53,6 +54,7 @@ internal CsvReader(Stream stream, bool leaveOpen, CsvReaderOptions? options = nu _stream = stream; _leaveOpen = leaveOpen; } + _sheetList = new ExcelSheetList([new CsvSheet(this)]); } private static bool NeedsTranscoding(Encoding? encoding) @@ -68,6 +70,7 @@ internal CsvReader(ReadOnlyMemory data, CsvReaderOptions? options = null) _stream = null; _leaveOpen = true; _memory = Transcode(data, _options.Encoding); + _sheetList = new ExcelSheetList([new CsvSheet(this)]); } internal CsvReaderOptions Options @@ -199,6 +202,48 @@ public void MoveToSheet(int index) ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, SheetCount); } + /// Gets the source's sheets: always exactly one, unnamed. Reading the list opens nothing. + /// The reader was disposed. + public ExcelSheetList Sheets + { + get + { + Lifetime.ThrowIfClosed(this); + return _sheetList; + } + } + + /// Checks whether is the (empty) name of the CSV sheet, ignoring case. + /// The sheet name to look for. + /// The CSV sheet, when is empty. + /// if is empty; otherwise . + /// The reader was disposed. + public bool TryGetSheet(ReadOnlySpan name, out CsvSheet sheet) + { + Lifetime.ThrowIfClosed(this); + bool found = name.IsEmpty; + sheet = found ? _sheetList[0] : default; + return found; + } + + IExcelSheet IExcelWorkbook.SheetAt(int index) + { + return Sheets[index]; + } + + bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) + { + bool found = TryGetSheet(name, out CsvSheet typed); + sheet = found ? typed : null; + return found; + } + + /// Gets the source's only sheet. + /// The workbook was disposed. + public CsvSheet FirstSheet => Sheets[0]; + + IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + /// Gets an enumerator that reads records synchronously from the start of the source. /// The reader was disposed. /// The source is a non-seekable stream that was already enumerated. @@ -207,7 +252,7 @@ public Enumerator GetEnumerator() return OpenSheet(); } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() + IExcelRowEnumerator IExcelSheet.GetEnumerator() { return GetEnumerator(); } @@ -222,7 +267,7 @@ public Enumerator GetAsyncEnumerator(CancellationToken ct = default) return OpenSheet(ct); } - IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) { return GetAsyncEnumerator(ct); } diff --git a/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs new file mode 100644 index 00000000..aea13d07 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs @@ -0,0 +1,52 @@ +namespace ExcelReader.Core.Reader.Csv +{ + /// The single, unnamed sheet of a . A value that holds no resources. + public readonly record struct CsvSheet : IExcelSheet, IExcelSheet + { + private readonly CsvReader? _reader; + + internal CsvSheet(CsvReader reader) + { + _reader = reader; + } + + /// + public int Index => 0; + + /// Gets the sheet name. Always the empty string, since a CSV source has a single, unnamed sheet. + public string Name => ""; + + /// Gets the sheet's visibility. Always : delimited text has no tab bar to hide from. + public ExcelSheetVisibility Visibility => ExcelSheetVisibility.Visible; + + /// + public bool IsDate1904 => false; + + internal CsvReader Reader => _reader ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + + /// + /// The source is a non-seekable stream that was already enumerated. + public CsvReader.Enumerator GetEnumerator() + { + return Reader.OpenSheet(); + } + + /// + /// The source is a non-seekable stream that was already enumerated. + public CsvReader.Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Reader.OpenSheet(ct); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) + { + return GetAsyncEnumerator(ct); + } + } +} diff --git a/src/ExcelReader.Core/Reader/ExcelSheetList.cs b/src/ExcelReader.Core/Reader/ExcelSheetList.cs new file mode 100644 index 00000000..c0d52cbe --- /dev/null +++ b/src/ExcelReader.Core/Reader/ExcelSheetList.cs @@ -0,0 +1,48 @@ +using System.Collections; +using ExcelReader.Core.Reader.Internal; + +namespace ExcelReader.Core.Reader +{ + /// The sheets of a workbook, in workbook order. Indexing or enumerating the list opens nothing. + /// The workbook's sheet type. + public sealed class ExcelSheetList : IReadOnlyList + { + private readonly TSheet[] _sheets; + + internal ExcelSheetList(TSheet[] sheets) + { + _sheets = sheets; + } + + /// Gets the number of sheets. + public int Count => _sheets.Length; + + /// Gets the sheet at the given zero-based index. + /// The zero-based sheet index. Must be within [0, Count). + /// is outside [0, Count). + public TSheet this[int index] + { + get + { + WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); + return _sheets[index]; + } + } + + /// Gets an enumerator over the sheets that allocates nothing. + public ArraySegment.Enumerator GetEnumerator() + { + return new ArraySegment(_sheets).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs index c2e7192b..32c844b8 100644 --- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs +++ b/src/ExcelReader.Core/Reader/IExcelRowReader.cs @@ -6,19 +6,9 @@ namespace ExcelReader.Core.Reader /// without boxing to the format-agnostic . /// /// The concrete row enumerator type this reader produces. - public interface IExcelRowReader + public interface IExcelRowReader : IExcelSheet where TEnumerator : IExcelRowEnumerator { - /// Gets a value indicating whether the workbook's date system is 1904-based rather than the default 1900-based system. - bool IsDate1904 { get; } - - /// Gets an enumerator that reads the current sheet's rows synchronously from the start. - TEnumerator GetEnumerator(); - - /// Gets an enumerator that reads the current sheet's rows asynchronously from the start. - /// Setup that needs I/O, such as opening the sheet part or loading shared strings, runs on the first , so this call does not block. - /// A token observed by that deferred setup and by every call. - TEnumerator GetAsyncEnumerator(CancellationToken ct = default); } /// @@ -39,14 +29,14 @@ public interface IExcelRowReader /// to new enumerators; its file or stream is released when the last enumerator is disposed. /// /// - public interface IExcelRowReader : IExcelRowReader, IDisposable, IAsyncDisposable + public interface IExcelRowReader : IExcelRowReader, IExcelWorkbook { + /// Gets a value indicating whether the workbook's date system is 1904-based rather than the default 1900-based system. + new bool IsDate1904 { get; } + /// Gets the name of the currently selected sheet. string SheetName { get; } - /// Gets the number of sheets in the workbook. - int SheetCount { get; } - /// Gets the name of the sheet at the given zero-based index, without changing the current sheet. /// The zero-based sheet index. Must be within [0, SheetCount). string SheetNameAt(int index); diff --git a/src/ExcelReader.Core/Reader/IExcelWorkbook.cs b/src/ExcelReader.Core/Reader/IExcelWorkbook.cs new file mode 100644 index 00000000..5f19a4ae --- /dev/null +++ b/src/ExcelReader.Core/Reader/IExcelWorkbook.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ExcelReader.Core.Reader +{ + /// + /// An open workbook of any supported format (XLSX, XLSB, XLS, CSV): its sheets and the state they + /// share. Sheets are obtained from it and read independently. + /// + /// + /// Thread safety: once opened, a workbook is safe to use from several threads: reading its + /// metadata, obtaining sheets and creating enumerators. Each enumerator is used by one thread at a + /// time; several enumerators of one workbook, including two over the same sheet, may run on + /// different threads. Using a single enumerator from two threads at once is undefined behaviour + /// and is not checked. A stream passed to a workbook must not be used by the caller while the + /// workbook is open. + /// + /// Disposing the workbook closes it to new sheets and enumerators. Its file, stream and pooled + /// buffers are released when the last enumerator obtained from it is disposed. + /// + /// + public interface IExcelWorkbook : IDisposable, IAsyncDisposable + { + /// Gets a value indicating whether the workbook's date system is 1904-based rather than the default 1900-based system. + bool IsDate1904 { get; } + + /// Gets the number of sheets in the workbook. + int SheetCount { get; } + + /// Gets the workbook's first sheet: the same sheet as SheetAt(0). Opens nothing. + /// The workbook was disposed. + IExcelSheet FirstSheet { get; } + + /// Gets the sheet at the given zero-based index. Opens nothing. + /// The zero-based sheet index. Must be within [0, SheetCount). + /// is outside [0, SheetCount). + /// The workbook was disposed. + IExcelSheet SheetAt(int index); + + /// Finds a sheet by name, ignoring case. Opens nothing. + /// The sheet name to look for. + /// The matching sheet, when one is found. + /// if a sheet with that name exists; otherwise . + /// The workbook was disposed. + bool TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet); + } + + /// + /// One sheet's rows, typed to the concrete enumerator it hands out + /// () so callers read without boxing to + /// . + /// + /// The concrete row enumerator type this sheet produces. + public interface IExcelSheet + where TEnumerator : IExcelRowEnumerator + { + /// Gets a value indicating whether the sheet's workbook uses the 1904 date system. + bool IsDate1904 { get; } + + /// Gets a new enumerator that reads the sheet's rows synchronously from the start. + /// The sheet's workbook was disposed. + TEnumerator GetEnumerator(); + + /// Gets a new enumerator that reads the sheet's rows asynchronously from the start. + /// Setup that needs I/O, such as opening the sheet part or loading shared strings, runs on the first , so this call does not block. + /// A token observed by that deferred setup and by every call. + /// The sheet's workbook was disposed. + TEnumerator GetAsyncEnumerator(CancellationToken ct = default); + } + + /// A sheet of an : its position, name and visibility, and its rows. + /// A sheet holds no resources. Every call to starts an independent read. + public interface IExcelSheet : IExcelSheet + { + /// Gets the sheet's zero-based position in its workbook. + int Index { get; } + + /// Gets the sheet's name. Empty for the single sheet of a CSV source. + string Name { get; } + + /// Gets whether the sheet is shown in the workbook's tab bar. + /// Reported, never enforced: a hidden sheet enumerates its rows like any other. + ExcelSheetVisibility Visibility { get; } + } +} diff --git a/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs b/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs index 4ea572dd..95d2942c 100644 --- a/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs +++ b/src/ExcelReader.Core/Reader/Internal/ReaderLifetime.cs @@ -22,6 +22,11 @@ internal void Acquire(object owner) } } + internal void ThrowIfClosed(object owner) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _closed) != 0, owner); + } + internal void Release() { if (Interlocked.Decrement(ref _count) == 0) diff --git a/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs new file mode 100644 index 00000000..dde2edba --- /dev/null +++ b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs @@ -0,0 +1,53 @@ +namespace ExcelReader.Core.Reader.Xls +{ + /// One sheet of an . A value that holds no resources; each enumerator it creates reads independently. + public readonly record struct XlsSheet : IExcelSheet, IExcelSheet + { + private readonly XlsWorkbook? _workbook; + + internal XlsSheet(XlsWorkbook workbook, int index, string name, ExcelSheetVisibility visibility) + { + _workbook = workbook; + Index = index; + Name = name; + Visibility = visibility; + IsDate1904 = workbook.IsDate1904; + } + + /// + public int Index { get; } + + /// + public string Name { get; } + + /// + public ExcelSheetVisibility Visibility { get; } + + /// + public bool IsDate1904 { get; } + + private XlsWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + + /// + public XlsWorkbook.Enumerator GetEnumerator() + { + return Workbook.OpenSheet(Index); + } + + /// + public XlsWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return Workbook.OpenSheetAsync(Index, ct); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) + { + return GetAsyncEnumerator(ct); + } + } +} diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs index 44aafd04..72defb83 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using ExcelReader.Core.Reader.Internal; using static ExcelReader.Core.Reader.Xlsb.Biff12; @@ -16,6 +17,7 @@ public sealed partial class XlsWorkbook : IExcelRowReader, IExcelRowReader _sheetList; private int _current; internal ReaderLifetime Lifetime { get; } @@ -44,6 +46,7 @@ private XlsWorkbook(WorkbookStream workbook, ExcelReaderOptions? options = null) workbook.Dispose(); throw new InvalidDataException("The workbook contains no sheets."); } + _sheetList = CreateSheetList(); } internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -123,13 +126,62 @@ public void MoveToSheet(int index) _current = index; } - /// - public Enumerator GetEnumerator() + private ExcelSheetList CreateSheetList() + { + XlsSheet[] sheets = new XlsSheet[_sheets.Length]; + for (int i = 0; i < sheets.Length; i++) + { + sheets[i] = new XlsSheet(this, i, _sheets[i].Name, _sheets[i].Visibility); + } + return new ExcelSheetList(sheets); + } + + /// Gets the workbook's sheets, in workbook order. Reading the list opens nothing. + /// The workbook was disposed. + public ExcelSheetList Sheets + { + get + { + Lifetime.ThrowIfClosed(this); + return _sheetList; + } + } + + /// Finds a sheet by name, ignoring case. Opens nothing. + /// The sheet name to look for. + /// The matching sheet, when one is found. + /// if a sheet with that name exists; otherwise . + /// The workbook was disposed. + public bool TryGetSheet(ReadOnlySpan name, out XlsSheet sheet) + { + Lifetime.ThrowIfClosed(this); + if (WorkbookLookups.TryFindSheetIndex(_sheets, name, static s => s.Name, out int index)) + { + sheet = _sheetList[index]; + return true; + } + sheet = default; + return false; + } + + IExcelSheet IExcelWorkbook.SheetAt(int index) + { + return Sheets[index]; + } + + bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) + { + bool found = TryGetSheet(name, out XlsSheet typed); + sheet = found ? typed : null; + return found; + } + + internal Enumerator OpenSheet(int index) { Lifetime.Acquire(this); try { - return new Enumerator(this, _sheets[_current].Offset); + return new Enumerator(this, _sheets[index].Offset); } catch { @@ -138,20 +190,13 @@ public Enumerator GetEnumerator() } } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() - { - return GetEnumerator(); - } - - /// - /// XlsWorkbook is fully in-memory, so nothing is deferred; is checked here and on each move. - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + internal Enumerator OpenSheetAsync(int index, CancellationToken ct) { ct.ThrowIfCancellationRequested(); Lifetime.Acquire(this); try { - return new Enumerator(this, _sheets[_current].Offset, ct); + return new Enumerator(this, _sheets[index].Offset, ct); } catch { @@ -160,7 +205,31 @@ public Enumerator GetAsyncEnumerator(CancellationToken ct = default) } } - IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) + /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. + /// The workbook was disposed. + public XlsSheet FirstSheet => Sheets[0]; + + IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + + /// + public Enumerator GetEnumerator() + { + return OpenSheet(_current); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// XlsWorkbook is fully in-memory, so nothing is deferred; is checked here and on each move. + public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return OpenSheetAsync(_current, ct); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) { return GetAsyncEnumerator(ct); } diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs new file mode 100644 index 00000000..21f86956 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs @@ -0,0 +1,53 @@ +namespace ExcelReader.Core.Reader.Xlsb +{ + /// One sheet of an . A value that holds no resources; each enumerator it creates reads independently. + public readonly record struct XlsbSheet : IExcelSheet, IExcelSheet + { + private readonly XlsbWorkbook? _workbook; + + internal XlsbSheet(XlsbWorkbook workbook, int index, string name, ExcelSheetVisibility visibility) + { + _workbook = workbook; + Index = index; + Name = name; + Visibility = visibility; + IsDate1904 = workbook.IsDate1904; + } + + /// + public int Index { get; } + + /// + public string Name { get; } + + /// + public ExcelSheetVisibility Visibility { get; } + + /// + public bool IsDate1904 { get; } + + private XlsbWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + + /// + public XlsbWorkbook.Enumerator GetEnumerator() + { + return Workbook.OpenSheet(Index); + } + + /// + public XlsbWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return Workbook.OpenSheetAsync(Index, ct); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) + { + return GetAsyncEnumerator(ct); + } + } +} diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index 3e4f33e5..29863f3b 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics.CodeAnalysis; using ExcelReader.Core.Reader.Internal; using ExcelReader.Core.Reader.Sources; using ExcelReader.Core.Reader.Zip; @@ -23,6 +24,7 @@ public sealed partial class XlsbWorkbook : IExcelRowReader, IExcelRowReader _sheetList; private int _current; internal ReaderLifetime Lifetime { get; } @@ -35,6 +37,7 @@ internal XlsbWorkbook(byte[] sharedFlat, int[] sharedOffsets, bool[] styleIsDate _sharedOffsets = sharedOffsets; _styleIsDate = styleIsDate; IsDate1904 = date1904; + _sheetList = CreateSheetList(); } internal XlsbWorkbook(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null) @@ -61,6 +64,7 @@ private XlsbWorkbook(ZipIndex zip, ExcelReaderOptions options) _styleIsDate = XlsbStyles.ParseStyleDateFlags(stylesPart.Memory.Span); IsDate1904 = XlsbWorkbookParts.ParseDate1904(wbPart.Memory.Span); (_sharedFlat, _sharedOffsets, _pooledSharedFlat) = LoadSharedStrings(zip, _decompressedBytes, options); + _sheetList = CreateSheetList(); } catch { @@ -83,6 +87,7 @@ private XlsbWorkbook(ZipIndex zip, _sharedFlat = sharedFlat; _sharedOffsets = sharedOffsets; _pooledSharedFlat = pooledSharedFlat; + _sheetList = CreateSheetList(); } internal static XlsbWorkbook CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null) @@ -223,13 +228,63 @@ internal bool IsDateStyle(int style) return WorkbookLookups.IsDateStyle(_styleIsDate, style); } - /// - public Enumerator GetEnumerator() + private ExcelSheetList CreateSheetList() + { + (string Name, string Path, ExcelSheetVisibility Visibility)[] source = _sheets ?? []; + XlsbSheet[] sheets = new XlsbSheet[source.Length]; + for (int i = 0; i < sheets.Length; i++) + { + sheets[i] = new XlsbSheet(this, i, source[i].Name, source[i].Visibility); + } + return new ExcelSheetList(sheets); + } + + /// Gets the workbook's sheets, in workbook order. Reading the list opens nothing. + /// The workbook was disposed. + public ExcelSheetList Sheets + { + get + { + Lifetime.ThrowIfClosed(this); + return _sheetList; + } + } + + /// Finds a sheet by name, ignoring case. Opens nothing. + /// The sheet name to look for. + /// The matching sheet, when one is found. + /// if a sheet with that name exists; otherwise . + /// The workbook was disposed. + public bool TryGetSheet(ReadOnlySpan name, out XlsbSheet sheet) + { + Lifetime.ThrowIfClosed(this); + if (WorkbookLookups.TryFindSheetIndex(_sheets ?? [], name, static s => s.Name, out int index)) + { + sheet = _sheetList[index]; + return true; + } + sheet = default; + return false; + } + + IExcelSheet IExcelWorkbook.SheetAt(int index) + { + return Sheets[index]; + } + + bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) + { + bool found = TryGetSheet(name, out XlsbSheet typed); + sheet = found ? typed : null; + return found; + } + + internal Enumerator OpenSheet(int index) { Lifetime.Acquire(this); try { - ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![_current].Path); + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets![index].Path); return new Enumerator(this, _zip!.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); } catch @@ -239,22 +294,16 @@ public Enumerator GetEnumerator() } } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() - { - return GetEnumerator(); - } - - /// - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + internal Enumerator OpenSheetAsync(int index, CancellationToken ct) { if (_zip!.HasMemory) { - return GetEnumerator(); + return OpenSheet(index); } Lifetime.Acquire(this); try { - return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets![_current].Path), ct); + return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets![index].Path), ct); } catch { @@ -263,7 +312,30 @@ public Enumerator GetAsyncEnumerator(CancellationToken ct = default) } } - IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) + /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. + /// The workbook was disposed. + public XlsbSheet FirstSheet => Sheets[0]; + + IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + + /// + public Enumerator GetEnumerator() + { + return OpenSheet(_current); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + /// + public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return OpenSheetAsync(_current, ct); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) { return GetAsyncEnumerator(ct); } diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs new file mode 100644 index 00000000..1ee1b744 --- /dev/null +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs @@ -0,0 +1,53 @@ +namespace ExcelReader.Core.Reader.Xlsx +{ + /// One sheet of an . A value that holds no resources; each enumerator it creates reads independently. + public readonly record struct XlsxSheet : IExcelSheet, IExcelSheet + { + private readonly XlsxWorkbook? _workbook; + + internal XlsxSheet(XlsxWorkbook workbook, int index, string name, ExcelSheetVisibility visibility) + { + _workbook = workbook; + Index = index; + Name = name; + Visibility = visibility; + IsDate1904 = workbook.IsDate1904; + } + + /// + public int Index { get; } + + /// + public string Name { get; } + + /// + public ExcelSheetVisibility Visibility { get; } + + /// + public bool IsDate1904 { get; } + + private XlsxWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + + /// + public XlsxWorkbook.Enumerator GetEnumerator() + { + return Workbook.OpenSheet(Index); + } + + /// + public XlsxWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return Workbook.OpenSheetAsync(Index, ct); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) + { + return GetAsyncEnumerator(ct); + } + } +} diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index b39942db..5e2ca1cd 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -14,6 +14,7 @@ public sealed partial class XlsxWorkbook : IExcelRowReader, IExcelRowReader _sheetList; private int _current; [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")] @@ -47,6 +48,7 @@ private XlsxWorkbook(ZipIndex zip, ExcelReaderOptions options) using ZipPart stylesPart = zip.OpenPartOrDefault("xl/styles.xml"u8, _decompressedBytes); _styleIsDate = ParseStyleDateFlags(stylesPart.Memory.Span); IsDate1904 = ParseDate1904(wbPart.Memory.Span); + _sheetList = CreateSheetList(); } catch { @@ -66,6 +68,7 @@ private XlsxWorkbook(ZipIndex zip, _sheets = sheets; _styleIsDate = styleIsDate; IsDate1904 = date1904; + _sheetList = CreateSheetList(); } internal static XlsxWorkbook CreateFromMemory(ReadOnlyMemory data, ExcelReaderOptions? options = null) @@ -155,14 +158,63 @@ public void MoveToSheet(int index) _current = index; } - /// - public Enumerator GetEnumerator() + private ExcelSheetList CreateSheetList() + { + XlsxSheet[] sheets = new XlsxSheet[_sheets.Length]; + for (int i = 0; i < sheets.Length; i++) + { + sheets[i] = new XlsxSheet(this, i, _sheets[i].Name, _sheets[i].Visibility); + } + return new ExcelSheetList(sheets); + } + + /// Gets the workbook's sheets, in workbook order. Reading the list opens nothing. + /// The workbook was disposed. + public ExcelSheetList Sheets + { + get + { + Lifetime.ThrowIfClosed(this); + return _sheetList; + } + } + + /// Finds a sheet by name, ignoring case. Opens nothing. + /// The sheet name to look for. + /// The matching sheet, when one is found. + /// if a sheet with that name exists; otherwise . + /// The workbook was disposed. + public bool TryGetSheet(ReadOnlySpan name, out XlsxSheet sheet) + { + Lifetime.ThrowIfClosed(this); + if (WorkbookLookups.TryFindSheetIndex(_sheets, name, static s => s.Name, out int index)) + { + sheet = _sheetList[index]; + return true; + } + sheet = default; + return false; + } + + IExcelSheet IExcelWorkbook.SheetAt(int index) + { + return Sheets[index]; + } + + bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) + { + bool found = TryGetSheet(name, out XlsxSheet typed); + sheet = found ? typed : null; + return found; + } + + internal Enumerator OpenSheet(int index) { Lifetime.Acquire(this); try { EnsureSharedLoaded(); - ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[_current].Path); + ZipEntryRef entry = WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path); return new Enumerator(this, _zip.OpenEntryStream(entry, _decompressedBytes, _options), entry.UncompressedSize); } catch @@ -172,22 +224,16 @@ public Enumerator GetEnumerator() } } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() - { - return GetEnumerator(); - } - - /// - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + internal Enumerator OpenSheetAsync(int index, CancellationToken ct) { if (_zip.HasMemory) { - return GetEnumerator(); + return OpenSheet(index); } Lifetime.Acquire(this); try { - return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets[_current].Path), ct); + return new Enumerator(this, WorkbookLookups.GetWorksheetEntry(_zip, _sheets[index].Path), ct); } catch { @@ -196,7 +242,30 @@ public Enumerator GetAsyncEnumerator(CancellationToken ct = default) } } - IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator(CancellationToken ct) + /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. + /// The workbook was disposed. + public XlsxSheet FirstSheet => Sheets[0]; + + IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + + /// + public Enumerator GetEnumerator() + { + return OpenSheet(_current); + } + + IExcelRowEnumerator IExcelSheet.GetEnumerator() + { + return GetEnumerator(); + } + + /// + public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + { + return OpenSheetAsync(_current, ct); + } + + IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) { return GetAsyncEnumerator(ct); } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index bd0aedd0..fb7fa64f 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Text; using ExcelReader.Core; @@ -117,6 +118,19 @@ private sealed class FailAfterNRowsReader(IExcelRowReader inner, int failAfter) public bool IsDate1904 => inner.IsDate1904; public string SheetName => inner.SheetName; public int SheetCount => inner.SheetCount; + + public IExcelSheet FirstSheet => inner.FirstSheet; + + public IExcelSheet SheetAt(int index) + { + return inner.SheetAt(index); + } + + public bool TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) + { + return inner.TryGetSheet(name, out sheet); + } + public string SheetNameAt(int index) { return inner.SheetNameAt(index); diff --git a/tests/ExcelReader.Tests/Reader/WorkbookSheetTests.cs b/tests/ExcelReader.Tests/Reader/WorkbookSheetTests.cs new file mode 100644 index 00000000..75dbe0f1 --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/WorkbookSheetTests.cs @@ -0,0 +1,327 @@ +using System.Globalization; +using ExcelReader.Core; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Xls; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xls; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Reader +{ + public sealed class WorkbookSheetTests + { + public enum Format + { + Xlsx, + Xlsb, + Xls, + } + + private static readonly string[] _names = ["Alpha", "Beta", "Gamma"]; + private static readonly ExcelSheetVisibility[] _visibility = + [ExcelSheetVisibility.Visible, ExcelSheetVisibility.Hidden, ExcelSheetVisibility.VeryHidden]; + + private const int Rows = 50; + + private static void Fill(IWorkbookWriter workbook) + where TSheet : ISheetWriter + where TRow : IRowWriter + { + for (int s = 0; s < _names.Length; s++) + { + TSheet sheet = workbook.AddSheet(_names[s], _visibility[s]); + for (int r = 0; r < Rows; r++) + { + using TRow row = sheet.StartRow(); + row.Write(_names[s] + "-" + r.ToString(CultureInfo.InvariantCulture)); + row.Write((s * 1000) + r); + } + sheet.End(); + sheet.Dispose(); + } + workbook.End(); + } + + private static byte[] Build(Format format) + { + using MemoryStream buffer = new(); + switch (format) + { + case Format.Xlsx: + using (XlsxWorkbookWriter xlsx = XlsxWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(xlsx); + } + break; + case Format.Xlsb: + using (XlsbWorkbookWriter xlsb = XlsbWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(xlsb); + } + break; + default: + using (XlsWorkbookWriter xls = XlsWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(xls); + } + break; + } + return buffer.ToArray(); + } + + private static IExcelWorkbook Open(Format format) + { + byte[] bytes = Build(format); + return format switch + { + Format.Xlsx => Excel.FromXlsx(bytes), + Format.Xlsb => Excel.FromXlsb(bytes), + _ => Excel.FromXls(bytes), + }; + } + + private static List Drain(IExcelRowEnumerator e) + { + List values = []; + while (e.MoveNext()) + { + values.Add(e.Current[0].GetString()); + } + return values; + } + + private static List ExpectedRows(int sheet) + { + List expected = []; + for (int r = 0; r < Rows; r++) + { + expected.Add(_names[sheet] + "-" + r.ToString(CultureInfo.InvariantCulture)); + } + return expected; + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void Sheets_Carry_Index_Name_And_Visibility(Format format) + { + using IExcelWorkbook workbook = Open(format); + Assert.Equal(3, workbook.SheetCount); + for (int s = 0; s < 3; s++) + { + IExcelSheet sheet = workbook.SheetAt(s); + Assert.Equal(s, sheet.Index); + Assert.Equal(_names[s], sheet.Name); + Assert.Equal(_visibility[s], sheet.Visibility); + Assert.Equal(workbook.IsDate1904, sheet.IsDate1904); + } + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void Each_Sheet_Enumerates_Its_Own_Rows_In_Any_Order(Format format) + { + using IExcelWorkbook workbook = Open(format); + foreach (int s in new[] { 2, 0, 1, 2 }) + { + using IExcelRowEnumerator e = workbook.SheetAt(s).GetEnumerator(); + Assert.Equal(ExpectedRows(s), Drain(e), StringComparer.Ordinal); + } + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public async Task Each_Sheet_Enumerates_Asynchronously(Format format) + { + using IExcelWorkbook workbook = Open(format); + for (int s = 0; s < 3; s++) + { + List values = []; + await using IExcelRowEnumerator e = workbook.SheetAt(s).GetAsyncEnumerator(TestContext.Current.CancellationToken); + while (await e.MoveNextAsync()) + { + values.Add(e.Current[0].GetString()); + } + Assert.Equal(ExpectedRows(s), values, StringComparer.Ordinal); + } + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void Interleaved_Enumerators_Keep_Their_Own_Sheet(Format format) + { + using IExcelWorkbook workbook = Open(format); + using IExcelRowEnumerator first = workbook.SheetAt(0).GetEnumerator(); + using IExcelRowEnumerator second = workbook.SheetAt(1).GetEnumerator(); + using IExcelRowEnumerator again = workbook.SheetAt(0).GetEnumerator(); + for (int r = 0; r < Rows; r++) + { + Assert.True(first.MoveNext()); + Assert.True(second.MoveNext()); + Assert.True(again.MoveNext()); + Assert.Equal(ExpectedRows(0)[r], first.Current[0].GetString()); + Assert.Equal(ExpectedRows(1)[r], second.Current[0].GetString()); + Assert.Equal(ExpectedRows(0)[r], again.Current[0].GetString()); + } + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void TryGetSheet_Ignores_Case_And_Reports_A_Miss(Format format) + { + using IExcelWorkbook workbook = Open(format); + Assert.True(workbook.TryGetSheet("bETA", out IExcelSheet? found)); + Assert.Equal(1, found.Index); + Assert.Equal("Beta", found.Name); + Assert.False(workbook.TryGetSheet("Delta", out IExcelSheet? missing)); + Assert.Null(missing); + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void A_Sheet_Index_Out_Of_Range_Is_Rejected(Format format) + { + using IExcelWorkbook workbook = Open(format); + Assert.Throws(() => workbook.SheetAt(-1)); + Assert.Throws(() => workbook.SheetAt(3)); + } + + [Theory] + [InlineData(Format.Xlsx)] + [InlineData(Format.Xlsb)] + [InlineData(Format.Xls)] + public void A_Disposed_Workbook_Refuses_Sheets_And_Enumerators(Format format) + { + IExcelWorkbook workbook = Open(format); + IExcelSheet taken = workbook.SheetAt(0); + workbook.Dispose(); + + Assert.Throws(() => workbook.SheetAt(0)); + Assert.Throws(() => workbook.TryGetSheet("Alpha", out _)); + Assert.Throws(() => taken.GetEnumerator()); + Assert.Throws(() => taken.GetAsyncEnumerator(TestContext.Current.CancellationToken)); + Assert.Equal("Alpha", taken.Name); + } + + [Fact] + public void The_Typed_Xlsx_List_Indexes_Enumerates_And_Compares() + { + using XlsxWorkbook workbook = Excel.FromXlsx(Build(Format.Xlsx)); + ExcelSheetList sheets = workbook.Sheets; + + Assert.Equal(3, sheets.Count); + Assert.Same(sheets, workbook.Sheets); + Assert.Equal(sheets[1], sheets[1]); + Assert.NotEqual(sheets[0], sheets[1]); + Assert.Throws(() => sheets[3]); + + List names = []; + foreach (XlsxSheet sheet in sheets) + { + names.Add(sheet.Name); + } + Assert.Equal(_names, names, StringComparer.Ordinal); + Assert.Equal(_names, sheets.Select(s => s.Name), StringComparer.Ordinal); + + Assert.True(workbook.TryGetSheet("gamma", out XlsxSheet gamma)); + using XlsxWorkbook.Enumerator e = gamma.GetEnumerator(); + Assert.Equal(ExpectedRows(2), Drain(e), StringComparer.Ordinal); + + workbook.Dispose(); + Assert.Throws(() => workbook.Sheets); + } + + [Fact] + public void The_Typed_Xlsb_And_Xls_Lists_Hand_Out_Typed_Enumerators() + { + using XlsbWorkbook xlsb = Excel.FromXlsb(Build(Format.Xlsb)); + using XlsbWorkbook.Enumerator b = xlsb.Sheets[1].GetEnumerator(); + Assert.Equal(ExpectedRows(1), Drain(b), StringComparer.Ordinal); + Assert.True(xlsb.TryGetSheet("ALPHA", out XlsbSheet alphaB)); + Assert.Equal(0, alphaB.Index); + + using XlsWorkbook xls = Excel.FromXls(Build(Format.Xls)); + using XlsWorkbook.Enumerator x = xls.Sheets[2].GetEnumerator(); + Assert.Equal(ExpectedRows(2), Drain(x), StringComparer.Ordinal); + Assert.True(xls.TryGetSheet("beta", out XlsSheet betaX)); + Assert.Equal(ExcelSheetVisibility.Hidden, betaX.Visibility); + } + + [Fact] + public void A_Csv_Is_A_Workbook_With_One_Unnamed_Visible_Sheet() + { + using CsvReader csv = Excel.FromCsv("a,b\n1,2\n"u8.ToArray()); + IExcelWorkbook workbook = csv; + + Assert.Equal(1, workbook.SheetCount); + Assert.Single(csv.Sheets); + CsvSheet sheet = csv.Sheets[0]; + Assert.Equal(0, sheet.Index); + Assert.Equal("", sheet.Name); + Assert.Equal(ExcelSheetVisibility.Visible, sheet.Visibility); + Assert.False(sheet.IsDate1904); + Assert.True(workbook.TryGetSheet("", out IExcelSheet? unnamed)); + Assert.Equal(0, unnamed.Index); + Assert.False(csv.TryGetSheet("Sheet1", out _)); + Assert.Throws(() => workbook.SheetAt(1)); + + using CsvReader.Enumerator e = sheet.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal("a", e.Current[0].GetString()); + Assert.True(e.MoveNext()); + Assert.Equal("2", e.Current[1].GetString()); + Assert.False(e.MoveNext()); + } + + [Fact] + public void A_Default_Sheet_Is_Inert_But_Does_Not_Crash_On_Inspection() + { + XlsxSheet sheet = default; + Assert.Equal(0, sheet.Index); + Assert.Null(sheet.Name); + Assert.Equal(default, sheet); + Assert.NotNull(sheet.ToString()); + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Throws(() => default(XlsbSheet).GetEnumerator()); + Assert.Throws(() => default(XlsSheet).GetEnumerator()); + Assert.Throws(() => default(CsvSheet).GetEnumerator()); + } + + [Fact] + public void FirstSheet_Is_The_Sheet_At_Index_Zero_On_Every_Workbook() + { + using XlsxWorkbook xlsx = Excel.FromXlsx(Build(Format.Xlsx)); + Assert.Equal(xlsx.Sheets[0], xlsx.FirstSheet); + using XlsbWorkbook xlsb = Excel.FromXlsb(Build(Format.Xlsb)); + Assert.Equal(xlsb.Sheets[0], xlsb.FirstSheet); + using XlsWorkbook xls = Excel.FromXls(Build(Format.Xls)); + Assert.Equal(xls.Sheets[0], xls.FirstSheet); + using CsvReader csv = Excel.FromCsv("a\n"u8.ToArray()); + Assert.Equal(csv.Sheets[0], csv.FirstSheet); + + IExcelWorkbook agnostic = xlsx; + Assert.Equal("Alpha", agnostic.FirstSheet.Name); + using XlsxWorkbook.Enumerator e = xlsx.FirstSheet.GetEnumerator(); + Assert.Equal(ExpectedRows(0), Drain(e), StringComparer.Ordinal); + + xlsx.Dispose(); + Assert.Throws(() => xlsx.FirstSheet); + Assert.Throws(() => agnostic.FirstSheet); + } + } +} From ff96e675af1f8df2140ac4e3da9a3eaaaa5b76c7 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 15:45:08 -0300 Subject: [PATCH 03/49] feat(core): parse, infer, adapt and convert from a sheet Adds sheet overloads next to the reader ones: ExcelParser.Parse, Excel.InferSchema, ExcelDataReader and ToArrowRecordBatch. ExcelEnumerable and AsyncRowEnumerator now constrain to IExcelSheet; CsvEnumerable holds a CsvSheet. Co-Authored-By: Claude Sonnet 5.5 --- .../ArrowConversionExtensions.cs | 21 +- src/ExcelReader.Arrow/ArrowWriteExtensions.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../Parser/AsyncRowEnumerator.cs | 14 +- src/ExcelReader.Core/Parser/CsvEnumerable.cs | 28 +-- .../Parser/ExcelEnumerable.cs | 28 +-- src/ExcelReader.Core/Parser/ExcelParser.cs | 49 ++++- .../Parser/ParallelCsv/ParallelCsvFactory.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 34 +++- src/ExcelReader.Core/Reader/Excel.cs | 44 ++++ .../Reader/ExcelDataReader.cs | 45 +++- .../Reader/SchemaInferenceTests.cs | 2 +- .../Reader/SheetConsumerTests.cs | 192 ++++++++++++++++++ 13 files changed, 408 insertions(+), 54 deletions(-) create mode 100644 tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs diff --git a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs index 74f378d6..621a9ca5 100644 --- a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs @@ -30,17 +30,34 @@ public static RecordBatch ToArrowRecordBatch(this IExcelRowReader reader, ExcelC { ArgumentNullException.ThrowIfNull(reader); ExcelColumnSchema[] resolvedSchema = schema ?? Excel.InferSchema(reader, headerRow); + using IExcelRowEnumerator rows = reader.GetEnumerator(); + return Build(rows, reader.IsDate1904, resolvedSchema, headerRow); + } + /// Converts one sheet into an Arrow . + /// The sheet to convert. + /// The column schema to use; inferred from the sheet with when . + /// 1-based row number holding the column names. + /// A record batch with one column per schema entry and one row per data row. + /// is . + public static RecordBatch ToArrowRecordBatch(this IExcelSheet sheet, ExcelColumnSchema[]? schema = null, int headerRow = 1) + { + ArgumentNullException.ThrowIfNull(sheet); + schema ??= Excel.InferSchema(sheet, headerRow); + using IExcelRowEnumerator rows = sheet.GetEnumerator(); + return Build(rows, sheet.IsDate1904, schema, headerRow); + } + + private static RecordBatch Build(IExcelRowEnumerator rows, bool isDate1904, ExcelColumnSchema[] resolvedSchema, int headerRow) + { ColumnAppender[] appenders = new ColumnAppender[resolvedSchema.Length]; for (int i = 0; i < resolvedSchema.Length; i++) { appenders[i] = ColumnAppender.Create(resolvedSchema[i]); } - using IExcelRowEnumerator rows = reader.GetEnumerator(); SkipHeaderRow(rows, headerRow); - bool isDate1904 = reader.IsDate1904; int rowCount = 0; while (rows.MoveNext()) { diff --git a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs index a1860064..5e1b0178 100644 --- a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs @@ -11,7 +11,7 @@ namespace ExcelReader.Arrow { /// /// Writes an Apache Arrow to a workbook as one sheet — the write-side - /// mirror of . + /// mirror of . /// public static class ArrowWriteExtensions { diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c581..3d76c3e8 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! diff --git a/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs b/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs index 5fe304f0..48d169b4 100644 --- a/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs +++ b/src/ExcelReader.Core/Parser/AsyncRowEnumerator.cs @@ -7,7 +7,7 @@ namespace ExcelReader.Core.Parser { /// Base class supplying the shared row-advancement loop for an asynchronous, format-specific row enumerator. /// The row model type each derived enumerator yields. - /// The concrete row reader type used to lazily open the row cursor. + /// The sheet type used to lazily open the row cursor. /// The concrete row enumerator type this instance drives. /// /// Mirrors , plus lazy @@ -18,18 +18,18 @@ namespace ExcelReader.Core.Parser /// XlsxWorkbook.Enumerator.MoveNextAsync / CsvReader.Enumerator.MoveNextAsync). /// [EditorBrowsable(EditorBrowsableState.Never)] - public abstract class AsyncRowEnumerator : IAsyncEnumerator + public abstract class AsyncRowEnumerator : IAsyncEnumerator where T : allows ref struct - where TReader : IExcelRowReader + where TSheet : IExcelSheet where TRows : class, IExcelRowEnumerator { - private readonly TReader _reader; + private readonly TSheet _sheet; private readonly CancellationToken _ct; private protected TRows? Rows; - private protected AsyncRowEnumerator(TReader reader, CancellationToken ct) + private protected AsyncRowEnumerator(TSheet sheet, CancellationToken ct) { - _reader = reader; + _sheet = sheet; _ct = ct; } @@ -45,7 +45,7 @@ private protected AsyncRowEnumerator(TReader reader, CancellationToken ct) /// public ValueTask MoveNextAsync() { - Rows ??= _reader.GetAsyncEnumerator(_ct); + Rows ??= _sheet.GetAsyncEnumerator(_ct); while (true) { ValueTask moveTask = Rows.MoveNextAsync(); diff --git a/src/ExcelReader.Core/Parser/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/CsvEnumerable.cs index a0f8260e..d935aad4 100644 --- a/src/ExcelReader.Core/Parser/CsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/CsvEnumerable.cs @@ -21,24 +21,24 @@ namespace ExcelReader.Core.Parser public sealed class CsvEnumerable : IEnumerable, IAsyncEnumerable where T : allows ref struct { - private readonly CsvReader _reader; + private readonly CsvSheet _sheet; private readonly ExcelParserConfig _config; private readonly CancellationToken _ct; private readonly TypeMapInfo _info; - private readonly bool _ownsReader; + private readonly CsvReader? _ownedReader; - internal CsvEnumerable(CsvReader reader, ExcelParserConfig config, TypeMapInfo info, bool ownsReader, CancellationToken ct) + internal CsvEnumerable(CsvSheet sheet, ExcelParserConfig config, TypeMapInfo info, CsvReader? ownedReader, CancellationToken ct) { - _reader = reader; + _sheet = sheet; _config = config; _info = info; _ct = ct; - _ownsReader = ownsReader; + _ownedReader = ownedReader; } - internal CsvEnumerable(CsvReader reader, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal CsvEnumerable(CsvSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) { - _reader = reader; + _sheet = sheet; _config = config; _info = explicitInfo; } @@ -46,7 +46,7 @@ internal CsvEnumerable(CsvReader reader, ExcelParserConfig config, TypeMapInfo public Enumerator GetEnumerator() { - CsvReader.Enumerator rows = _reader.GetEnumerator(); + CsvReader.Enumerator rows = _sheet.GetEnumerator(); return new Enumerator(rows, _info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, _config.ThrowOnParseFailure); } @@ -69,7 +69,7 @@ IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken can public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { CancellationToken effective = cancellationToken.CanBeCanceled ? cancellationToken : _ct; - return new AsyncEnumerator(_reader, _info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, _config.ThrowOnParseFailure, _ownsReader, effective); + return new AsyncEnumerator(_sheet, _info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, _config.ThrowOnParseFailure, _ownedReader, effective); } /// Enumerates CSV rows synchronously, projecting each into a instance by fixed field index. @@ -104,25 +104,25 @@ private protected override T Project() /// Enumerates CSV rows asynchronously, projecting each into a instance by fixed field index. [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class AsyncEnumerator : AsyncRowEnumerator + public sealed class AsyncEnumerator : AsyncRowEnumerator { private CsvRowProjector _projector; private readonly CsvReader? _ownedReader; internal AsyncEnumerator( - CsvReader reader, + CsvSheet sheet, TypeMapInfo typeInfo, StringComparer comparer, HeaderNormalization normalization, int headerRow, IFormatProvider provider, bool throwOnParseFailure, - bool ownsReader, + CsvReader? ownedReader, CancellationToken ct) - : base(reader, ct) + : base(sheet, ct) { _projector = new CsvRowProjector(typeInfo, comparer, normalization, headerRow, provider, throwOnParseFailure); - _ownedReader = ownsReader ? reader : null; + _ownedReader = ownedReader; } /// diff --git a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs index e5094ad3..f27915c3 100644 --- a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs @@ -20,23 +20,23 @@ internal ExcelEnumerable(XlsxWorkbook reader, ExcelParserConfig config, TypeMapI /// Lazily projects rows read by a given reader/enumerator pair into instances, for both synchronous and asynchronous enumeration. /// The row model type to bind each row to. - /// The concrete row reader type this instance pulls rows from. - /// The concrete row enumerator type produces. + /// The sheet type this instance pulls rows from. + /// The concrete row enumerator type produces. [EditorBrowsable(EditorBrowsableState.Never)] [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Public nested Enumerator/AsyncEnumerator are the standard foreach/await-foreach pattern.")] - public class ExcelEnumerable : IEnumerable, IAsyncEnumerable + public class ExcelEnumerable : IEnumerable, IAsyncEnumerable where T : allows ref struct - where TReader : IExcelRowReader + where TSheet : IExcelSheet where TEnumerator : class, IExcelRowEnumerator { - private readonly TReader _reader; + private readonly TSheet _sheet; private readonly ExcelParserConfig _config; private readonly TypeMapInfo _info; - internal ExcelEnumerable(TReader reader, ExcelParserConfig config, TypeMapInfo explicitInfo) + internal ExcelEnumerable(TSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) { - _reader = reader; + _sheet = sheet; _config = config; _info = explicitInfo; } @@ -46,8 +46,8 @@ internal ExcelEnumerable(TReader reader, ExcelParserConfig config, TypeMapInfo IEnumerable.GetEnumerator() @@ -70,7 +70,7 @@ IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken can Justification = "T allows ref struct so a row model can be a class, a struct or a ref struct; constraining it would break that.")] public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new AsyncEnumerator(_reader, _info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, _config.ThrowOnParseFailure, cancellationToken); + return new AsyncEnumerator(_sheet, _info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, _config.ThrowOnParseFailure, cancellationToken); } /// Enumerates rows synchronously, projecting each into a instance. @@ -107,12 +107,12 @@ private protected override T Project() /// Enumerates rows asynchronously, projecting each into a instance. [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class AsyncEnumerator : AsyncRowEnumerator + public sealed class AsyncEnumerator : AsyncRowEnumerator { private RowProjector _projector; internal AsyncEnumerator( - TReader reader, + TSheet sheet, TypeMapInfo typeInfo, StringComparer comparer, HeaderNormalization normalization, @@ -120,9 +120,9 @@ internal AsyncEnumerator( IFormatProvider provider, bool throwOnParseFailure, CancellationToken ct) - : base(reader, ct) + : base(sheet, ct) { - _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, reader.IsDate1904, provider, throwOnParseFailure); + _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, sheet.IsDate1904, provider, throwOnParseFailure); } private protected override ProjectionStep Classify() diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 5b8c0f56..566f39f5 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -91,7 +91,54 @@ public ExcelEnumerable Parse(IExcelRowR public CsvEnumerable Parse(CsvReader reader) { ArgumentNullException.ThrowIfNull(reader); - return new CsvEnumerable(reader, _config, _csvInfo()); + return new CsvEnumerable(reader.Sheets[0], _config, _csvInfo()); + } + + /// Parses the rows of an XLSX sheet into instances, lazily as the result is enumerated. + /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. + /// An enumerable that yields one per data row. + public ExcelEnumerable Parse(XlsxSheet sheet) + { + return new ExcelEnumerable(sheet, _config, _info()); + } + + /// Parses the rows of an XLS sheet into instances, lazily as the result is enumerated. + /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. + /// An enumerable that yields one per data row. + public ExcelEnumerable Parse(XlsSheet sheet) + { + return new ExcelEnumerable(sheet, _config, _info()); + } + + /// Parses the rows of an XLSB sheet into instances, lazily as the result is enumerated. + /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. + /// An enumerable that yields one per data row. + public ExcelEnumerable Parse(XlsbSheet sheet) + { + return new ExcelEnumerable(sheet, _config, _info()); + } + + /// Parses the rows of a format-agnostic sheet (one obtained from ) into instances, lazily as the result is enumerated. + /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. + /// An enumerable that yields one per data row. + /// is . + public ExcelEnumerable Parse(IExcelSheet sheet) + { + ArgumentNullException.ThrowIfNull(sheet); + return new ExcelEnumerable(sheet, _config, _info()); + } + + /// Parses the records of a CSV sheet into instances, lazily as the result is enumerated. + /// The CSV sheet to pull records from. + /// An enumerable that yields one per data row. + /// + /// Uses a specialized enumerable (dense field binding, single-pass projection), since CSV rows have + /// no gaps or styles. Prefer this overload over for CSV: a sheet + /// held as goes through the generic path. + /// + public CsvEnumerable Parse(CsvSheet sheet) + { + return new CsvEnumerable(sheet, _config, _csvInfo()); } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs index b8c742a8..3ce1d617 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvFactory.cs @@ -116,7 +116,7 @@ private static IAsyncEnumerable Build( private static CsvEnumerable Sequential( CsvReader reader, ExcelParser parser, CancellationToken ct) { - return new CsvEnumerable(reader, parser.Config, parser.CsvInfo, ownsReader: true, ct); + return new CsvEnumerable(reader.Sheets[0], parser.Config, parser.CsvInfo, ownedReader: reader, ct); } private static async IAsyncEnumerable Empty() diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index fc467397..5ffd7a89 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -1,7 +1,17 @@ #nullable enable +*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator +*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T +*REMOVED*ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable +*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator +*REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.Enumerator *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool *REMOVED*ExcelReader.Core.Reader.Xls.XlsReader *REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.Dispose() -> void *REMOVED*ExcelReader.Core.Reader.Xls.XlsReader.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -70,8 +80,22 @@ *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +*REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! +*REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! +ExcelReader.Core.Parser.AsyncRowEnumerator +ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T +ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask +ExcelReader.Core.Parser.ExcelEnumerable +ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator +ExcelReader.Core.Parser.ExcelEnumerable.Enumerator +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> ExcelReader.Core.Parser.CsvEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelSheet! sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Reader.Csv.CsvReader.FirstSheet.get -> ExcelReader.Core.Reader.Csv.CsvSheet ExcelReader.Core.Reader.Csv.CsvReader.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! @@ -85,6 +109,7 @@ ExcelReader.Core.Reader.Csv.CsvSheet.Index.get -> int ExcelReader.Core.Reader.Csv.CsvSheet.IsDate1904.get -> bool ExcelReader.Core.Reader.Csv.CsvSheet.Name.get -> string! ExcelReader.Core.Reader.Csv.CsvSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1) -> void ExcelReader.Core.Reader.ExcelSheetList ExcelReader.Core.Reader.ExcelSheetList.Count.get -> int ExcelReader.Core.Reader.ExcelSheetList.GetEnumerator() -> System.ArraySegment.Enumerator @@ -214,12 +239,17 @@ static ExcelReader.Core.Reader.Excel.FromXlsx(System.ReadOnlyMemory data, static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! static ExcelReader.Core.Reader.Xls.XlsSheet.operator !=(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool static ExcelReader.Core.Reader.Xls.XlsSheet.operator ==(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator !=(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator ==(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator !=(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator ==(ExcelReader.Core.Reader.Xlsx.XlsxSheet left, ExcelReader.Core.Reader.Xlsx.XlsxSheet right) -> bool +virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask +~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! +~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! ~override ExcelReader.Core.Reader.Csv.CsvSheet.Equals(object obj) -> bool ~override ExcelReader.Core.Reader.Csv.CsvSheet.ToString() -> string ~override ExcelReader.Core.Reader.Xls.XlsSheet.Equals(object obj) -> bool @@ -228,7 +258,3 @@ static ExcelReader.Core.Reader.Xlsx.XlsxSheet.operator ==(ExcelReader.Core.Reade ~override ExcelReader.Core.Reader.Xlsb.XlsbSheet.ToString() -> string ~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(object obj) -> bool ~override ExcelReader.Core.Reader.Xlsx.XlsxSheet.ToString() -> string -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator -*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index a907ce8f..d5eff04b 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -314,6 +314,50 @@ public static ExcelColumnSchema[] InferSchema(IExcelRowReader reader, int header return SchemaInference.Infer(rows, reader.IsDate1904, headerRow, sampleSize, parseText); } + /// Guesses a column schema for by sampling it from the first row. + /// The sheet to sample. A new enumerator is opened and disposed; no other read of the sheet is disturbed. + /// 1-based row number to take column names from; 0 means "no header", + /// so every returned schema is addressable only by . + /// How many rows after the header to inspect. + /// One per column, in column order. + /// + /// This is a guess over a bounded sample, not a guarantee about the whole sheet. Verify it fits + /// before trusting it, and feed the result into to build a real map. + /// + /// is . + /// is negative, or + /// is not positive. + /// The sheet has fewer rows than . + public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow, int sampleSize) + { + return InferSchema(sheet, headerRow, sampleSize, parseText: false); + } + + /// + /// Guesses a column schema by sampling the sheet's rows, as + /// does, and can also type cells that hold text. + /// + /// The sheet to sample. A new enumerator is opened and disposed; no other read of the sheet is disturbed. + /// 1-based row number to take column names from; 0 means "no header", + /// so every returned schema is addressable only by . + /// How many rows after the header to inspect. + /// When , a text cell (every CSV field, or a number + /// stored as text) counts as an integer, a decimal, true/false, or an ISO-8601 date or + /// date-time when its text has exactly that shape. Numbers with a leading zero (00123), scientific + /// notation (12E4), padded or culture-formatted numbers, and non-ISO dates stay text. A column only gets a non-text type when + /// every sampled value converts to it. + /// One per column, in column order. + /// is . + /// is negative, or + /// is not positive. + /// The sheet has fewer rows than . + public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) + { + ArgumentNullException.ThrowIfNull(sheet); + using IExcelRowEnumerator rows = sheet.GetEnumerator(); + return SchemaInference.Infer(rows, sheet.IsDate1904, headerRow, sampleSize, parseText); + } + private static ReadOnlySpan ZipSignature => [0x50, 0x4B, 0x03, 0x04]; /// diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/ExcelDataReader.cs index 7ec63e4c..390c063d 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs +++ b/src/ExcelReader.Core/Reader/ExcelDataReader.cs @@ -57,28 +57,55 @@ public ExcelDataReader(IExcelRowReader reader, int headerRow = 1) _isDate1904 = reader.IsDate1904; _sheetName = reader.SheetName; _rows = reader.GetEnumerator(); - _pendingConsumed = true; + (_names, _ordinals, _hasPendingRow, _pendingConsumed) = Initialize(_rows, headerRow); + } + + /// + /// Wraps . + /// + /// The sheet to read. Its workbook stays owned by the caller. + /// + /// The 1-based row holding column names. Pass 0 for a header-less sheet, whose columns come back + /// named "Column0", "Column1", ... and whose count is taken from the first data row. + /// + /// is null. + /// is negative. + public ExcelDataReader(IExcelSheet sheet, int headerRow = 1) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentOutOfRangeException.ThrowIfNegative(headerRow); + _isDate1904 = sheet.IsDate1904; + _sheetName = sheet.Name; + _rows = sheet.GetEnumerator(); + (_names, _ordinals, _hasPendingRow, _pendingConsumed) = Initialize(_rows, headerRow); + } + + private static (string?[] Names, Dictionary Ordinals, bool HasPendingRow, bool PendingConsumed) Initialize(IExcelRowEnumerator rows, int headerRow) + { try { - if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(_rows, headerRow, out _)) + string?[] names; + bool hasPendingRow = false; + bool pendingConsumed = true; + if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(rows, headerRow, out _)) { - _names = ReadHeaderNames(_rows.Current); + names = ReadHeaderNames(rows.Current); } else if (headerRow > 0) { - _names = []; + names = []; } else { - _hasPendingRow = _rows.MoveNext(); - _pendingConsumed = false; - _names = _hasPendingRow ? new string?[_rows.Current.ColumnCount] : []; + hasPendingRow = rows.MoveNext(); + pendingConsumed = false; + names = hasPendingRow ? new string?[rows.Current.ColumnCount] : []; } - _ordinals = BuildOrdinals(_names); + return (names, BuildOrdinals(names), hasPendingRow, pendingConsumed); } catch { - _rows.Dispose(); + rows.Dispose(); throw; } } diff --git a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs index 14a613f4..195b9317 100644 --- a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs +++ b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs @@ -316,7 +316,7 @@ public void Should_NotDisturbTheReader_When_InferSchemaRunsBeforeEnumeration() [Fact] public void Should_Throw_When_ReaderIsNull() { - Assert.Throws(() => Excel.InferSchema(null!)); + Assert.Throws(() => Excel.InferSchema((IExcelRowReader)null!)); } } } diff --git a/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs new file mode 100644 index 00000000..88b396ec --- /dev/null +++ b/tests/ExcelReader.Tests/Reader/SheetConsumerTests.cs @@ -0,0 +1,192 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using Apache.Arrow; +using ExcelReader.Arrow; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Csv; +using ExcelReader.Core.Reader.Schema; +using ExcelReader.Core.Reader.Xls; +using ExcelReader.Core.Reader.Xlsb; +using ExcelReader.Core.Reader.Xlsx; +using ExcelReader.Core.Writer; +using ExcelReader.Core.Writer.Xls; +using ExcelReader.Core.Writer.Xlsb; +using ExcelReader.Core.Writer.Xlsx; + +namespace ExcelReader.Tests.Reader +{ + public sealed class SheetConsumerTests + { + public sealed class Item + { + [ExcelColumn("Name")] + public string Name { get; set; } = ""; + + [ExcelColumn("Amount")] + public int Amount { get; set; } + } + + private const int Rows = 20; + + private static void Fill(IWorkbookWriter workbook) + where TSheet : ISheetWriter + where TRow : IRowWriter + { + for (int s = 0; s < 2; s++) + { + TSheet sheet = workbook.AddSheet("S" + s.ToString(CultureInfo.InvariantCulture)); + using (TRow header = sheet.StartRow()) + { + header.Write("Name"); + header.Write("Amount"); + } + for (int r = 0; r < Rows; r++) + { + using TRow row = sheet.StartRow(); + row.Write("s" + s.ToString(CultureInfo.InvariantCulture) + "-" + r.ToString(CultureInfo.InvariantCulture)); + row.Write((s * 100) + r); + } + sheet.End(); + sheet.Dispose(); + } + workbook.End(); + } + + private static byte[] BuildXlsx() + { + using MemoryStream buffer = new(); + using (XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(workbook); + } + return buffer.ToArray(); + } + + private static byte[] BuildXlsb() + { + using MemoryStream buffer = new(); + using (XlsbWorkbookWriter workbook = XlsbWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(workbook); + } + return buffer.ToArray(); + } + + private static byte[] BuildXls() + { + using MemoryStream buffer = new(); + using (XlsWorkbookWriter workbook = XlsWorkbookWriter.Create(buffer, leaveOpen: true)) + { + Fill(workbook); + } + return buffer.ToArray(); + } + + private static byte[] BuildCsv() + { + System.Text.StringBuilder text = new("Name,Amount\n"); + for (int r = 0; r < Rows; r++) + { + text.Append("s1-").Append(r.ToString(CultureInfo.InvariantCulture)).Append(',') + .Append((100 + r).ToString(CultureInfo.InvariantCulture)).Append('\n'); + } + return System.Text.Encoding.UTF8.GetBytes(text.ToString()); + } + + private static void AssertSecondSheet(IEnumerable items) + { + List list = [.. items]; + Assert.Equal(Rows, list.Count); + for (int r = 0; r < Rows; r++) + { + Assert.Equal("s1-" + r.ToString(CultureInfo.InvariantCulture), list[r].Name); + Assert.Equal(100 + r, list[r].Amount); + } + } + + [Fact] + public void Parse_Reads_The_Sheet_It_Is_Given_For_Every_Typed_Overload() + { + ExcelParser parser = ExcelParser.FromAttributes(); + + using XlsxWorkbook xlsx = Excel.FromXlsx(BuildXlsx()); + AssertSecondSheet(parser.Parse(xlsx.Sheets[1])); + + using XlsbWorkbook xlsb = Excel.FromXlsb(BuildXlsb()); + AssertSecondSheet(parser.Parse(xlsb.Sheets[1])); + + using XlsWorkbook xls = Excel.FromXls(BuildXls()); + AssertSecondSheet(parser.Parse(xls.Sheets[1])); + + using CsvReader csv = Excel.FromCsv(BuildCsv()); + AssertSecondSheet(parser.Parse(csv.Sheets[0])); + } + + [Fact] + public async Task Parse_Over_A_Format_Agnostic_Sheet_Works_Sync_And_Async() + { + ExcelParser parser = ExcelParser.FromAttributes(); + using IExcelWorkbook workbook = Excel.Open(BuildXlsx()); + IExcelSheet sheet = workbook.SheetAt(1); + + AssertSecondSheet(parser.Parse(sheet)); + + List asyncItems = []; + await foreach (Item item in parser.Parse(sheet).WithCancellation(TestContext.Current.CancellationToken)) + { + asyncItems.Add(item); + } + AssertSecondSheet(asyncItems); + } + + [Fact] + public void One_Parse_Result_Can_Be_Enumerated_Twice_And_Sheets_Stay_Independent() + { + ExcelParser parser = ExcelParser.FromAttributes(); + using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); + var second = parser.Parse(workbook.Sheets[1]); + var first = parser.Parse(workbook.Sheets[0]); + + AssertSecondSheet(second); + Assert.Equal("s0-0", first.First().Name); + AssertSecondSheet(second); + } + + [Fact] + public void InferSchema_Samples_The_Sheet_It_Is_Given() + { + using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); + ExcelColumnSchema[] schema = Excel.InferSchema(workbook.Sheets[1]); + Assert.Equal(2, schema.Length); + Assert.Equal("Name", schema[0].Name); + Assert.Equal(ExcelColumnType.Int64Column, schema[1].Type); + Assert.Equal(schema.Length, Excel.InferSchema(workbook.Sheets[1], 1, 5).Length); + Assert.Throws(() => Excel.InferSchema((IExcelSheet)null!)); + } + + [Fact] + public void ExcelDataReader_Reads_The_Sheet_It_Is_Given_And_Names_Its_Table_After_It() + { + using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); + using ExcelDataReader data = new(workbook.Sheets[1]); + Assert.Equal(2, data.FieldCount); + Assert.True(data.Read()); + Assert.Equal("s1-0", data.GetString(0)); + using DataTable schema = data.GetSchemaTable()!; + Assert.Equal("S1", schema.Rows[0][SchemaTableColumn.BaseTableName]); + Assert.Throws(() => new ExcelDataReader((IExcelSheet)null!)); + } + + [Fact] + public void ToArrowRecordBatch_Converts_The_Sheet_It_Is_Given() + { + using XlsxWorkbook workbook = Excel.FromXlsx(BuildXlsx()); + using RecordBatch batch = workbook.Sheets[1].ToArrowRecordBatch(); + Assert.Equal(Rows, batch.Length); + Assert.Equal(2, batch.ColumnCount); + Assert.Equal("s1-0", ((StringArray)batch.Column(0)).GetString(0)); + } + } +} From 530d285a7d6d241d6ed5b44cbe9d4d73ef195a87 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:09:54 -0300 Subject: [PATCH 04/49] refactor(cli): resolve a workbook and a sheet instead of positioning a reader CliCommands.Open returns the workbook plus the selected sheet; the write and schema paths take IExcelSheet. The parallel-CSV internals open the CSV sheet directly through OpenSheet. Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Cli/CliCommands.cs | 85 +++++++++--------- .../Parser/ParallelCsv/CsvHeaderBinder.cs | 2 +- .../ParallelCsv/ParallelCsvProcessor.cs | 4 +- tests/ExcelReader.Tests/Cli/CliTests.cs | 87 ++++++++++++++----- .../Reader/Csv/CsvDialectAndFormatTests.cs | 26 ++++-- 5 files changed, 132 insertions(+), 72 deletions(-) diff --git a/src/ExcelReader.Cli/CliCommands.cs b/src/ExcelReader.Cli/CliCommands.cs index 87dd7fbe..0105637e 100644 --- a/src/ExcelReader.Cli/CliCommands.cs +++ b/src/ExcelReader.Cli/CliCommands.cs @@ -17,12 +17,12 @@ internal static int Sheets(string path, TextWriter stdout, TextWriter stderr, st { return Execute(() => { - using IExcelRowReader reader = Open(path, sheet: null, password); - for (int i = 0; i < reader.SheetCount; i++) + using IExcelWorkbook workbook = Open(path, sheet: null, out _, password); + for (int i = 0; i < workbook.SheetCount; i++) { stdout.Write(i.ToString(CultureInfo.InvariantCulture)); stdout.Write('\t'); - stdout.WriteLine(reader.SheetNameAt(i)); + stdout.WriteLine(workbook.SheetAt(i).Name); } return 0; }, stderr); @@ -36,7 +36,7 @@ internal static int Convert(string path, string? sheet, string? output, string? { string resolvedFormat = ResolveFormat(format, output); ThrowIfOutputIsADirectory(output); - using IExcelRowReader reader = Open(path, sheet, password, inputDelimiter); + using IExcelWorkbook workbook = Open(path, sheet, out IExcelSheet selected, password, inputDelimiter); bool leaveOpen = output is null; Stream target = leaveOpen @@ -47,16 +47,16 @@ internal static int Convert(string path, string? sheet, string? output, string? switch (resolvedFormat) { case "csv": - WriteCsv(reader, target, leaveOpen, delimiter, onProgress); + WriteCsv(selected, target, leaveOpen, delimiter, onProgress); break; case "xlsx": - WriteXlsx(reader, target, leaveOpen, onProgress); + WriteXlsx(selected, target, leaveOpen, onProgress); break; case "xlsb": - WriteXlsb(reader, target, leaveOpen, onProgress); + WriteXlsb(selected, target, leaveOpen, onProgress); break; case "xls": - WriteXls(reader, target, leaveOpen, onProgress); + WriteXls(selected, target, leaveOpen, onProgress); break; default: throw new UnreachableException($"unresolved format '{resolvedFormat}'."); @@ -112,41 +112,41 @@ private static void ThrowIfOutputIsADirectory(string? output) } } - private static void WriteCsv(IExcelRowReader reader, Stream target, bool leaveOpen, char delimiter, Action? onProgress) + private static void WriteCsv(IExcelSheet sheet, Stream target, bool leaveOpen, char delimiter, Action? onProgress) { using CsvWorkbookWriter workbook = CsvWorkbookWriter.Create(target, leaveOpen, new CsvWriterOptions { Delimiter = AsciiByte(delimiter, "delimiter") }); - WriteRows(workbook, reader, onProgress); + WriteRows(workbook, sheet, onProgress); } - private static void WriteXlsx(IExcelRowReader reader, Stream target, bool leaveOpen, Action? onProgress) + private static void WriteXlsx(IExcelSheet sheet, Stream target, bool leaveOpen, Action? onProgress) { using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(target, leaveOpen); - WriteRows(workbook, reader, onProgress); + WriteRows(workbook, sheet, onProgress); } - private static void WriteXlsb(IExcelRowReader reader, Stream target, bool leaveOpen, Action? onProgress) + private static void WriteXlsb(IExcelSheet sheet, Stream target, bool leaveOpen, Action? onProgress) { - using XlsbWorkbookWriter workbook = XlsbWorkbookWriter.Create(target, leaveOpen, new XlsbWriterOptions { Date1904 = reader.IsDate1904 }); - WriteRows(workbook, reader, onProgress); + using XlsbWorkbookWriter workbook = XlsbWorkbookWriter.Create(target, leaveOpen, new XlsbWriterOptions { Date1904 = sheet.IsDate1904 }); + WriteRows(workbook, sheet, onProgress); } - private static void WriteXls(IExcelRowReader reader, Stream target, bool leaveOpen, Action? onProgress) + private static void WriteXls(IExcelSheet sheet, Stream target, bool leaveOpen, Action? onProgress) { - using XlsWorkbookWriter workbook = XlsWorkbookWriter.Create(target, leaveOpen, date1904: reader.IsDate1904); - WriteRows(workbook, reader, onProgress); + using XlsWorkbookWriter workbook = XlsWorkbookWriter.Create(target, leaveOpen, date1904: sheet.IsDate1904); + WriteRows(workbook, sheet, onProgress); } private const int ProgressInterval = 500; - private static void WriteRows(TWorkbook workbook, IExcelRowReader reader, Action? onProgress) + private static void WriteRows(TWorkbook workbook, IExcelSheet sheet, Action? onProgress) where TWorkbook : IWorkbookWriter where TSheet : ISheetWriter where TRow : IRowWriter { - using TSheet sheetWriter = workbook.AddSheet(reader.SheetName); + using TSheet sheetWriter = workbook.AddSheet(sheet.Name); int rowCount = 0; - using IExcelRowEnumerator rows = reader.GetEnumerator(); + using IExcelRowEnumerator rows = sheet.GetEnumerator(); while (rows.MoveNext()) { using TRow row = sheetWriter.StartRow(); @@ -193,9 +193,9 @@ internal static int Schema(string path, string? sheet, int headerRow, int sample { return Execute(() => { - using IExcelRowReader reader = Open(path, sheet, password, inputDelimiter); + using IExcelWorkbook workbook = Open(path, sheet, out IExcelSheet selected, password, inputDelimiter); - foreach (ExcelColumnSchema column in Excel.InferSchema(reader, headerRow, sampleSize)) + foreach (ExcelColumnSchema column in Excel.InferSchema(selected, headerRow, sampleSize)) { stdout.Write(column.Index.ToString(CultureInfo.InvariantCulture)); stdout.Write('\t'); @@ -236,7 +236,7 @@ internal static byte AsciiByte(char value, string parameterName) return (byte)value; } - internal static IExcelRowReader Open(string path, string? sheet, string? password = null, char? inputDelimiter = null) + internal static IExcelWorkbook Open(string path, string? sheet, out IExcelSheet selected, string? password = null, char? inputDelimiter = null) { bool isCsv = string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase); CsvReaderOptions csv = inputDelimiter is char delimiter @@ -247,31 +247,34 @@ internal static IExcelRowReader Open(string path, string? sheet, string? passwor { options = options with { Password = password }; } - IExcelRowReader reader = Excel.Open(path, isCsv ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown, options); + IExcelWorkbook workbook = Excel.Open(path, isCsv ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown, options); + try + { + selected = SelectSheet(workbook, sheet, path); + return workbook; + } + catch + { + workbook.Dispose(); + throw; + } + } + private static IExcelSheet SelectSheet(IExcelWorkbook workbook, string? sheet, string path) + { if (sheet is null) { - return reader; + return workbook.SheetAt(0); } - - try + if (workbook.TryGetSheet(sheet, out IExcelSheet? named)) { - if (reader.TryMoveToSheet(sheet)) - { - return reader; - } - if (int.TryParse(sheet, CultureInfo.InvariantCulture, out int index)) - { - reader.MoveToSheet(index); - return reader; - } - throw new ArgumentException($"no sheet named '{sheet}' in {path}.", nameof(sheet)); + return named; } - catch + if (int.TryParse(sheet, CultureInfo.InvariantCulture, out int index)) { - reader.Dispose(); - throw; + return workbook.SheetAt(index); } + throw new ArgumentException($"no sheet named '{sheet}' in {path}.", nameof(sheet)); } } } diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs b/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs index 5426b1b1..faf29623 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/CsvHeaderBinder.cs @@ -11,7 +11,7 @@ internal static CsvBoundColumnMap Bind( TypeMapInfo info, out long firstDataRecordOffset) { - using CsvReader.Enumerator rows = reader.GetEnumerator(); + using CsvReader.Enumerator rows = reader.OpenSheet(); int rowNumber = 0; while (rows.MoveNext()) { diff --git a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs index 524bf172..b1822f87 100644 --- a/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs +++ b/src/ExcelReader.Core/Parser/ParallelCsv/ParallelCsvProcessor.cs @@ -101,7 +101,7 @@ private static async Task SequentialAsync( { await using (reader.ConfigureAwait(false)) { - CsvReader.Enumerator rows = reader.GetAsyncEnumerator(ct); + CsvReader.Enumerator rows = reader.OpenSheet(ct); await using (rows.ConfigureAwait(false)) { TState state = aggregation.Seed(); @@ -300,7 +300,7 @@ private static void Deliver(CsvRowAction accumulate, ref TState CsvReader csv = source.OpenReader(reader); await using (csv.ConfigureAwait(false)) { - CsvReader.Enumerator rows = csv.GetAsyncEnumerator(ct); + CsvReader.Enumerator rows = csv.OpenSheet(ct); await using (rows.ConfigureAwait(false)) { for (int i = 0; i <= records; i++) diff --git a/tests/ExcelReader.Tests/Cli/CliTests.cs b/tests/ExcelReader.Tests/Cli/CliTests.cs index 31db0f0a..75c774a6 100644 --- a/tests/ExcelReader.Tests/Cli/CliTests.cs +++ b/tests/ExcelReader.Tests/Cli/CliTests.cs @@ -5,8 +5,33 @@ namespace ExcelReader.Tests.Cli { - public sealed class CliTests + public sealed class CliTests : IDisposable { + private readonly List _temporaryFiles = []; + + public void Dispose() + { + foreach (string file in _temporaryFiles) + { + File.Delete(file); + } + } + + private string MultiSheetPath() + { + string path = Path.Combine(Path.GetTempPath(), $"cli-multi-sheet-{Guid.NewGuid():N}.xlsx"); + _temporaryFiles.Add(path); + using FileStream file = File.Create(path); + using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(file, leaveOpen: true); + foreach (string name in (string[])["First", "Second", "Third"]) + { + using XlsxSheetWriter sheet = workbook.AddSheet(name); + sheet.End(); + } + workbook.End(); + return path; + } + private static string Fixture(string name) { return Path.Combine("data", name); @@ -28,7 +53,7 @@ public void Should_ListEverySheet_When_RunningSheets() Assert.Equal(0, code); Assert.Empty(error); - using IExcelRowReader reader = Excel.Open(Fixture("RealExcel.xlsb")); + using IExcelWorkbook reader = Excel.Open(Fixture("RealExcel.xlsb")); string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(reader.SheetCount, lines.Length); @@ -37,7 +62,7 @@ public void Should_ListEverySheet_When_RunningSheets() string[] parts = lines[i].TrimEnd('\r').Split('\t'); Assert.Equal(2, parts.Length); Assert.Equal(i.ToString(System.Globalization.CultureInfo.InvariantCulture), parts[0]); - Assert.Equal(reader.SheetNameAt(i), parts[1]); + Assert.Equal(reader.SheetAt(i).Name, parts[1]); } } @@ -56,11 +81,11 @@ public void Should_ReturnOneAndWriteToStderr_When_TheFileDoesNotExist() [Fact] public void Should_SelectBySheetName_When_SheetIsNotNumeric() { - using IExcelRowReader byIndex = CliCommands.Open(Fixture("RealExcel.xlsb"), "0"); - string firstSheetName = byIndex.SheetName; + using IExcelWorkbook byIndex = CliCommands.Open(Fixture("RealExcel.xlsb"), "0", out IExcelSheet byIndexSheet); + string firstSheetName = byIndexSheet.Name; - using IExcelRowReader byName = CliCommands.Open(Fixture("RealExcel.xlsb"), firstSheetName); - Assert.Equal(firstSheetName, byName.SheetName); + using IExcelWorkbook byName = CliCommands.Open(Fixture("RealExcel.xlsb"), firstSheetName, out IExcelSheet byNameSheet); + Assert.Equal(firstSheetName, byNameSheet.Name); } [Fact] @@ -80,11 +105,11 @@ public void Should_PreferASheetNamedLikeANumber_Over_TheSheetAtThatIndex() workbook.End(); } - using IExcelRowReader named = CliCommands.Open(path, "0"); - Assert.Equal("0", named.SheetName); + using IExcelWorkbook named = CliCommands.Open(path, "0", out IExcelSheet namedSheet); + Assert.Equal("0", namedSheet.Name); - using IExcelRowReader indexed = CliCommands.Open(path, "1"); - Assert.Equal("0", indexed.SheetName); + using IExcelWorkbook indexed = CliCommands.Open(path, "1", out IExcelSheet indexedSheet); + Assert.Equal("0", indexedSheet.Name); } finally { @@ -96,7 +121,7 @@ public void Should_PreferASheetNamedLikeANumber_Over_TheSheetAtThatIndex() public void Should_Throw_When_TheNamedSheetIsAbsent() { ArgumentException error = Assert.Throws( - () => CliCommands.Open(Fixture("RealExcel.xlsb"), "NoSuchSheet")); + () => CliCommands.Open(Fixture("RealExcel.xlsb"), "NoSuchSheet", out _)); Assert.Contains("NoSuchSheet", error.Message, StringComparison.Ordinal); } @@ -104,16 +129,38 @@ public void Should_Throw_When_TheNamedSheetIsAbsent() [Fact] public void Should_DefaultToTheFirstSheet_When_SheetIsNull() { - using IExcelRowReader reader = CliCommands.Open(Fixture("RealExcel.xlsb"), null); + using IExcelWorkbook reader = CliCommands.Open(Fixture("RealExcel.xlsb"), null, out IExcelSheet selected); + + Assert.Equal(reader.SheetAt(0).Name, selected.Name); + } + + [Fact] + public void Should_FailWithAMessage_When_TheSheetIndexIsOutOfRange() + { + using StringWriter stdout = new(); + using StringWriter stderr = new(); + int exit = CliCommands.Schema(MultiSheetPath(), sheet: "7", headerRow: 1, sampleSize: 10, stdout, stderr); + Assert.Equal(1, exit); + Assert.NotEmpty(stderr.ToString()); + Assert.Empty(stdout.ToString()); + } + + [Fact] + public void Should_ReturnTheWorkbookAndTheSelectedSheet_When_Opening() + { + using IExcelWorkbook workbook = CliCommands.Open(MultiSheetPath(), sheet: "1", out IExcelSheet selected); + Assert.Equal(1, selected.Index); + Assert.Equal(workbook.SheetAt(1).Name, selected.Name); - Assert.Equal(reader.SheetNameAt(0), reader.SheetName); + using IExcelWorkbook first = CliCommands.Open(MultiSheetPath(), sheet: null, out IExcelSheet defaulted); + Assert.Equal(0, defaulted.Index); } [Fact] public void Should_OpenAnEncryptedWorkbook_When_PasswordIsGiven() { - using IExcelRowReader reader = CliCommands.Open( - Path.Combine("data", "encrypted", "agile-aes256-sha512.xlsx"), null, EncryptedFixtures.Password); + using IExcelWorkbook reader = CliCommands.Open( + Path.Combine("data", "encrypted", "agile-aes256-sha512.xlsx"), null, out _, EncryptedFixtures.Password); Assert.True(reader.SheetCount > 0); } @@ -210,10 +257,10 @@ public void Should_ConvertToAnotherSpreadsheetFormat_When_OutputExtensionNamesOn Assert.Empty(error); Assert.Empty(output); - using IExcelRowReader source = Excel.Open(Fixture("RealExcel.xlsb")); - using IExcelRowEnumerator sourceRows = source.GetEnumerator(); - using IExcelRowReader written = Excel.Open(target); - using IExcelRowEnumerator writtenRows = written.GetEnumerator(); + using IExcelWorkbook source = Excel.Open(Fixture("RealExcel.xlsb")); + using IExcelRowEnumerator sourceRows = source.FirstSheet.GetEnumerator(); + using IExcelWorkbook written = Excel.Open(target); + using IExcelRowEnumerator writtenRows = written.FirstSheet.GetEnumerator(); int rowCount = 0; while (sourceRows.MoveNext()) diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs index f0c6d805..02fd0b87 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs @@ -28,13 +28,23 @@ private static string WriteTemp(string content, Encoding? encoding = null) } private static List ReadAll(IExcelRowReader reader) + { + return ReadAll(reader.GetEnumerator()); + } + + private static List ReadAll(IExcelSheet sheet) + { + return ReadAll(sheet.GetEnumerator()); + } + + private static List ReadAll(IExcelRowEnumerator enumerator) { var rows = new List(); - using IExcelRowEnumerator enumerator = reader.GetEnumerator(); - while (enumerator.MoveNext()) + using IExcelRowEnumerator owned = enumerator; + while (owned.MoveNext()) { var cells = new List(); - foreach (RowCell cell in enumerator.Current.Cells) + foreach (RowCell cell in owned.Current.Cells) { cells.Add(cell.Value.GetString()); } @@ -201,9 +211,9 @@ public void Should_SniffDelimiter_When_TheCliReadsCsvWithoutAnInputDelimiter() string path = WriteTemp("Name;City;Age\nAda;London;36\n"); try { - using IExcelRowReader reader = CliCommands.Open(path, sheet: null); + using IExcelWorkbook reader = CliCommands.Open(path, sheet: null, out IExcelSheet selected); - Assert.Equal(["Ada", "London", "36"], ReadAll(reader)[1]); + Assert.Equal(["Ada", "London", "36"], ReadAll(selected)[1]); } finally { @@ -217,9 +227,9 @@ public void Should_UseTheGivenDelimiter_When_TheCliIsPassedAnInputDelimiter() string path = WriteTemp("Name;City;Age\nAda;London;36\n"); try { - using IExcelRowReader reader = CliCommands.Open(path, sheet: null, password: null, inputDelimiter: ','); + using IExcelWorkbook reader = CliCommands.Open(path, sheet: null, out IExcelSheet selected, password: null, inputDelimiter: ','); - Assert.Equal(["Ada;London;36"], ReadAll(reader)[1]); + Assert.Equal(["Ada;London;36"], ReadAll(selected)[1]); } finally { @@ -233,7 +243,7 @@ public void Should_Reject_When_TheCliIsPassedANonAsciiDelimiter() string path = WriteTemp("Name;Age\n"); try { - Assert.Throws(() => CliCommands.Open(path, sheet: null, password: null, inputDelimiter: 'ç')); + Assert.Throws(() => CliCommands.Open(path, sheet: null, out _, password: null, inputDelimiter: 'ç')); } finally { From 4ab5ee502652fe6ab3a07d244b4749790f1d1b7c Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:19:05 -0300 Subject: [PATCH 05/49] refactor(native): keep the current sheet in the handle, over a workbook NativeHandle now holds an IExcelWorkbook and its own current sheet index; the exported C functions keep their status codes and messages. Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Native/NativeHandle.cs | 20 ++- .../Reading/ReadApi.Open.cs | 12 +- .../Reading/ReadApi.Rows.All.cs | 2 +- .../Reading/ReadApi.Rows.cs | 4 +- .../Reading/ReadApi.Schema.cs | 4 +- .../Reading/ReadApi.Sheets.cs | 12 +- .../Typed/TypedApi.Parallel.cs | 6 +- .../Typed/TypedApi.Session.cs | 4 +- .../Native/NativeApiTests.Reading.cs | 127 +++++++++++++----- 9 files changed, 129 insertions(+), 62 deletions(-) diff --git a/src/ExcelReader.Native/NativeHandle.cs b/src/ExcelReader.Native/NativeHandle.cs index 2b38320c..db8849b0 100644 --- a/src/ExcelReader.Native/NativeHandle.cs +++ b/src/ExcelReader.Native/NativeHandle.cs @@ -6,13 +6,25 @@ namespace ExcelReader.Native { internal sealed class NativeHandle : IDisposable { - internal NativeHandle(IExcelRowReader reader) + internal NativeHandle(IExcelWorkbook workbook) { - Reader = reader; + Workbook = workbook; Scratch = new byte[4096]; } - internal IExcelRowReader Reader { get; } + internal IExcelWorkbook Workbook { get; } + + internal int CurrentSheet { get; private set; } + + internal IExcelSheet Sheet => Workbook.SheetAt(CurrentSheet); + + internal void MoveToSheet(int index) + { + _ = Workbook.SheetAt(index); + CurrentSheet = index; + ResetRows(); + FaultLiveSession("xl_move_to_sheet"); + } internal IExcelRowEnumerator? Rows { get; set; } @@ -66,7 +78,7 @@ public void Dispose() FaultLiveSession("xl_close"); ResetRows(); View?.Dispose(); - Reader.Dispose(); + Workbook.Dispose(); } } } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Open.cs b/src/ExcelReader.Native/Reading/ReadApi.Open.cs index 9c799882..e3fb428c 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Open.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Open.cs @@ -17,8 +17,8 @@ internal static int OpenFile(ReadOnlySpan utf8Path, int format, out Native try { string path = Encoding.UTF8.GetString(utf8Path); - IExcelRowReader reader = OpenReader(path, format, options ?? default); - handle = new NativeHandle(reader); + IExcelWorkbook workbook = OpenReader(path, format, options ?? default); + handle = new NativeHandle(workbook); return NativeStatus.Ok; } catch (ExcelEncryptionException ex) @@ -44,8 +44,8 @@ internal static int OpenMemory(ReadOnlySpan data, int format, out NativeHa try { byte[] copy = data.ToArray(); - IExcelRowReader reader = OpenReader(copy, format, options ?? default); - handle = new NativeHandle(reader); + IExcelWorkbook workbook = OpenReader(copy, format, options ?? default); + handle = new NativeHandle(workbook); return NativeStatus.Ok; } catch (ExcelEncryptionException ex) @@ -90,12 +90,12 @@ internal static int Close(NativeHandle? handle) } } - private static IExcelRowReader OpenReader(string path, int format, NativeOpenOptions options) + private static IExcelWorkbook OpenReader(string path, int format, NativeOpenOptions options) { return Excel.Open(path, MapFormat(format), options.ToExcelReaderOptions()); } - private static IExcelRowReader OpenReader(byte[] data, int format, NativeOpenOptions options) + private static IExcelWorkbook OpenReader(byte[] data, int format, NativeOpenOptions options) { return Excel.Open(data, MapFormat(format), options.ToExcelReaderOptions()); } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs index 83e8e336..f54ee48b 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs @@ -58,7 +58,7 @@ private static void AccumulateAllRows(NativeHandle handle) byte[] rowScratch = []; handle.FaultLiveSession("xl_read_all_blob"); - handle.Rows ??= handle.Reader.GetEnumerator(); + handle.Rows ??= handle.Sheet.GetEnumerator(); while (handle.Rows.MoveNext()) { Row row = handle.Rows.Current; diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs index 10e6364b..daebd2e5 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs @@ -50,7 +50,7 @@ internal static int NextRow(NativeHandle? handle, Span buffer, out int wri if (!handle.HasPending) { handle.FaultLiveSession("xl_next_row"); - handle.Rows ??= handle.Reader.GetEnumerator(); + handle.Rows ??= handle.Sheet.GetEnumerator(); if (!handle.Rows.MoveNext()) { return NativeStatus.Eof; @@ -100,7 +100,7 @@ internal static int NextRowView(NativeHandle? handle, out NativeRow row) } handle.FaultLiveSession("xl_next_row_view"); - handle.Rows ??= handle.Reader.GetEnumerator(); + handle.Rows ??= handle.Sheet.GetEnumerator(); if (!handle.Rows.MoveNext()) { return NativeStatus.Eof; diff --git a/src/ExcelReader.Native/Reading/ReadApi.Schema.cs b/src/ExcelReader.Native/Reading/ReadApi.Schema.cs index 6412992e..64dd2449 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Schema.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Schema.cs @@ -42,8 +42,8 @@ internal static int InferSchema(NativeHandle? handle, int headerRow, int sampleS try { handle.FaultLiveSession("xl_infer_schema"); - rows = handle.Reader.GetEnumerator(); - schema = BuildSchema(SchemaInference.Infer(rows, handle.Reader.IsDate1904, headerRow, sampleSize, (flags & InferParseText) != 0)); + rows = handle.Sheet.GetEnumerator(); + schema = BuildSchema(SchemaInference.Infer(rows, handle.Workbook.IsDate1904, headerRow, sampleSize, (flags & InferParseText) != 0)); return NativeStatus.Ok; } catch (ArgumentException exception) diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs index b5c5f3a4..0687f1ae 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs @@ -15,7 +15,7 @@ internal static int SheetCount(NativeHandle? handle, out int count) NativeApi.ClearLastError(); try { - count = handle.Reader.SheetCount; + count = handle.Workbook.SheetCount; return NativeStatus.Ok; } catch (Exception exception) @@ -36,7 +36,7 @@ internal static int SheetName(NativeHandle? handle, Span buffer, out int l NativeApi.ClearLastError(); try { - return CopyUtf8(handle.Reader.SheetName, buffer, out length); + return CopyUtf8(handle.Sheet.Name, buffer, out length); } catch (Exception exception) { @@ -60,7 +60,7 @@ internal static int SheetNameAt(NativeHandle? handle, int index, Span buff NativeApi.ClearLastError(); try { - return CopyUtf8(handle.Reader.SheetNameAt(index), buffer, out length); + return CopyUtf8(handle.Workbook.SheetAt(index).Name, buffer, out length); } catch (Exception exception) { @@ -92,9 +92,7 @@ internal static int MoveToSheet(NativeHandle? handle, int index) NativeApi.ClearLastError(); try { - handle.Reader.MoveToSheet(index); - handle.ResetRows(); - handle.FaultLiveSession("xl_move_to_sheet"); + handle.MoveToSheet(index); return NativeStatus.Ok; } catch (Exception exception) @@ -115,7 +113,7 @@ internal static int IsDate1904(NativeHandle? handle, out int flag) NativeApi.ClearLastError(); try { - flag = handle.Reader.IsDate1904 ? 1 : 0; + flag = handle.Workbook.IsDate1904 ? 1 : 0; return NativeStatus.Ok; } catch (Exception exception) diff --git a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs index 48babf85..12bf8939 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs @@ -24,7 +24,7 @@ internal static int ParseTypedTable(NativeHandle? handle, NativeColumnSpec[] spe return NativeStatus.InvalidArgument; } int dop = ParallelCsvFactory.Normalize(degreeOfParallelism); - if (dop > 1 && handle.Reader is CsvReader csv && csv.TryGetChunkSource(out CsvChunkSource source) + if (dop > 1 && handle.Workbook is CsvReader csv && csv.TryGetChunkSource(out CsvChunkSource source) && ParallelCsvFactory.CanPartition(dop, source.Length, csv.Options)) { LastParseRanInParallel = true; @@ -48,7 +48,7 @@ private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source { int[] columnIndices = new int[specs.Length]; using (CsvReader headerReader = source.OpenReader(reader)) - using (IExcelRowEnumerator rows = ((IExcelRowReader)headerReader).GetEnumerator()) + using (IExcelRowEnumerator rows = headerReader.OpenSheet()) { if (!TryResolveColumns(rows, specs, headerRow, columnIndices, out string? resolveError)) { @@ -57,7 +57,7 @@ private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source } } - bool isDate1904 = handle.Reader.IsDate1904; + bool isDate1904 = handle.Workbook.IsDate1904; CsvAggregation aggregation = new() { Seed = () => new PartitionTable(specs), diff --git a/src/ExcelReader.Native/Typed/TypedApi.Session.cs b/src/ExcelReader.Native/Typed/TypedApi.Session.cs index 270d9540..31da2c88 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Session.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Session.cs @@ -85,7 +85,7 @@ private static int OpenCore(NativeHandle handle, NativeColumnSpec[] specs, int h IExcelRowEnumerator? rows = null; try { - rows = handle.Reader.GetEnumerator(); + rows = handle.Sheet.GetEnumerator(); int[] columnIndices = new int[specs.Length]; if (!TryResolveColumns(rows, specs, headerRow, columnIndices, out string? resolveError)) { @@ -95,7 +95,7 @@ private static int OpenCore(NativeHandle handle, NativeColumnSpec[] specs, int h } session = new TypedParseSession(handle, specs, columnIndices, maxRows, - handle.Reader.IsDate1904, rows); + handle.Workbook.IsDate1904, rows); return NativeStatus.Ok; } catch (Exception exception) diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index fb7fa64f..ec525445 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -103,55 +103,60 @@ private static void AssertSpec((string? Name, int Index, int Type, bool Nullable Assert.Equal(nullable, spec.Nullable); } - private static NativeHandle NativeHandle_Create(IExcelRowReader reader) + private static NativeHandle NativeHandle_Create(IExcelWorkbook workbook) { - return new NativeHandle(reader); + return new NativeHandle(workbook); } /// - /// Wraps a real , forwarding everything except row enumeration: - /// its enumerator yields real rows and then throws, simulating a + /// Wraps a real , forwarding everything except row enumeration: + /// its sheets' enumerators yield real rows and then throw, simulating a /// genuine mid-sheet decode failure for . /// - private sealed class FailAfterNRowsReader(IExcelRowReader inner, int failAfter) : IExcelRowReader + private sealed class FailAfterNRowsWorkbook(IExcelWorkbook inner, int failAfter) : IExcelWorkbook { public bool IsDate1904 => inner.IsDate1904; - public string SheetName => inner.SheetName; + public int SheetCount => inner.SheetCount; - public IExcelSheet FirstSheet => inner.FirstSheet; + public IExcelSheet FirstSheet => SheetAt(0); public IExcelSheet SheetAt(int index) { - return inner.SheetAt(index); + return new FailingSheet(inner.SheetAt(index), failAfter); } public bool TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] out IExcelSheet sheet) { - return inner.TryGetSheet(name, out sheet); + if (inner.TryGetSheet(name, out IExcelSheet? found)) + { + sheet = new FailingSheet(found, failAfter); + return true; + } + sheet = null; + return false; } - public string SheetNameAt(int index) + public void Dispose() { - return inner.SheetNameAt(index); + inner.Dispose(); } - public ExcelSheetVisibility SheetVisibility => inner.SheetVisibility; - - public ExcelSheetVisibility SheetVisibilityAt(int index) + public ValueTask DisposeAsync() { - return inner.SheetVisibilityAt(index); + return inner.DisposeAsync(); } + } - public bool TryMoveToSheet(ReadOnlySpan name) - { - return inner.TryMoveToSheet(name); - } + private sealed class FailingSheet(IExcelSheet inner, int failAfter) : IExcelSheet + { + public int Index => inner.Index; - public void MoveToSheet(int index) - { - inner.MoveToSheet(index); - } + public string Name => inner.Name; + + public ExcelSheetVisibility Visibility => inner.Visibility; + + public bool IsDate1904 => inner.IsDate1904; public IExcelRowEnumerator GetEnumerator() { @@ -162,16 +167,6 @@ public IExcelRowEnumerator GetAsyncEnumerator(CancellationToken ct = default) { return new FailAfterNRowsEnumerator(inner.GetAsyncEnumerator(ct), failAfter); } - - public void Dispose() - { - inner.Dispose(); - } - - public ValueTask DisposeAsync() - { - return inner.DisposeAsync(); - } } [Theory] @@ -909,6 +904,68 @@ public void NextRow_Should_Read_Every_Row_Of_The_Xlsx_Fixture() } } + [Fact] + public void MovingToASheetChangesWhatSheetNameAndNextRowReport() + { + using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( + [ + ("First", """1"""), + ("Second", """10"""), + ]); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); + try + { + Span name = stackalloc byte[64]; + byte[] row = new byte[4096]; + Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int firstLength)); + Assert.Equal("First", Encoding.UTF8.GetString(name[..firstLength])); + + Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 1)); + Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int secondLength)); + Assert.Equal("Second", Encoding.UTF8.GetString(name[..secondLength])); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, row, out int written)); + Assert.Equal("10", DecodeRow(row.AsSpan(0, written))[0].Value); + + Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 0, name, out int atLength)); + Assert.Equal("First", Encoding.UTF8.GetString(name[..atLength])); + + Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 0)); + Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int backLength)); + Assert.Equal("First", Encoding.UTF8.GetString(name[..backLength])); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, row, out written)); + Assert.Equal("1", DecodeRow(row.AsSpan(0, written))[0].Value); + } + finally + { + ReadApi.Close(handle); + } + } + + [Fact] + public void MovingToASheetOutOfRangeFailsAndKeepsTheCurrentSheet() + { + using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( + [ + ("First", """1"""), + ("Second", """10"""), + ]); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); + try + { + Span name = stackalloc byte[64]; + Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 1)); + + Assert.Equal(NativeStatus.Error, ReadApi.MoveToSheet(handle, 99)); + Assert.Equal(NativeStatus.Error, ReadApi.MoveToSheet(handle, -1)); + Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int length)); + Assert.Equal("Second", Encoding.UTF8.GetString(name[..length])); + } + finally + { + ReadApi.Close(handle); + } + } + [Fact] public void NextRow_Should_Restart_After_MoveToSheet() { @@ -1044,8 +1101,8 @@ public void ReadAllDecoded_Should_Free_Already_Decoded_Rows_When_A_Later_Row_Fai { using (FileStream stream = File.OpenRead(path)) { - var reader = new FailAfterNRowsReader(Excel.FromCsv(stream, leaveOpen: true), failAfter: 2); - NativeHandle handle = NativeHandle_Create(reader); + var workbook = new FailAfterNRowsWorkbook(Excel.FromCsv(stream, leaveOpen: true), failAfter: 2); + NativeHandle handle = NativeHandle_Create(workbook); try { int status = ReadApi.ReadAllDecoded(handle, out NativeRows rows); From 1b4887b98fa27167ac2dc916b4dba8842f6ab626 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:26:31 -0300 Subject: [PATCH 06/49] test: read through sheets in the benchmarks, the fuzz harness and the AOT check Benchmarks, the fuzz harness, FuzzTests and the AOT sanity app obtain a sheet from the workbook (FirstSheet / SheetAt) instead of enumerating a reader with a current sheet. The differential harness is typed over IExcelWorkbook. Sheet lookup stays inside every measured method. Co-Authored-By: Claude Sonnet 5.5 --- tests/ExcelReader.AotSanity/Program.cs | 12 +++--- .../ColdStartBenchmark.cs | 6 +-- .../Crypto/EncryptedWorkbookBenchmark.cs | 30 ++++++------- .../Interop/ArrowConversionBenchmark.cs | 8 ++-- .../Interop/DataReaderBenchmark.cs | 24 +++++------ .../Parse/CsvParallelVsSepBenchmark.cs | 4 +- .../Parse/CsvParseBenchmark.cs | 4 +- .../Parse/ParseBenchmark.cs | 14 +++---- .../Read/CsvReadBenchmark.cs | 6 +-- .../Read/ReadBenchmark.cs | 10 ++--- .../Read/StringHeavyReadBenchmark.cs | 18 ++++---- .../Read/XlsReadBenchmark.cs | 4 +- .../Read/XlsxSharedStringHotPathBenchmark.cs | 4 +- .../RealData/RealDataReadBenchmark.cs | 42 +++++++++---------- .../RealData/RealDataTypedParseBenchmark.cs | 4 +- tests/ExcelReader.Fuzz/Harnesses.cs | 31 +++++++------- tests/ExcelReader.Tests/FuzzTests.cs | 5 +-- 17 files changed, 111 insertions(+), 115 deletions(-) diff --git a/tests/ExcelReader.AotSanity/Program.cs b/tests/ExcelReader.AotSanity/Program.cs index 0c081ab4..b44d06a2 100644 --- a/tests/ExcelReader.AotSanity/Program.cs +++ b/tests/ExcelReader.AotSanity/Program.cs @@ -14,14 +14,14 @@ private static async Task Main() { await using MemoryStream xlsx = await BuildSampleXlsxAsync(); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(xlsx); - var rows = ExcelParser.Generated().Parse(reader).ToList(); + var rows = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); if (rows.Count != 1 || !string.Equals(rows[0].Name, "Alice", StringComparison.Ordinal) || rows[0].Age != 30 || !rows[0].Active) { Console.Error.WriteLine("Mapped XLSX parse produced an unexpected result."); return 1; } - var generatedRows = ExcelParser.Generated().Parse(reader).ToList(); + var generatedRows = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); if (generatedRows.Count != 1 || !string.Equals(generatedRows[0].Name, "Alice", StringComparison.Ordinal) || generatedRows[0].Age != 30 || !generatedRows[0].Active) { @@ -38,7 +38,7 @@ await sheet.WriteRecordsAsync([new GeneratedAotModel { Name = "Zoe", Age = 8, Ac } writtenStream.Position = 0; await using XlsxWorkbook writtenReader = await Excel.FromXlsxAsync(writtenStream); - var writtenRows = ExcelParser.Generated().Parse(writtenReader).ToList(); + var writtenRows = ExcelParser.Generated().Parse(writtenReader.FirstSheet).ToList(); if (writtenRows.Count != 1 || !string.Equals(writtenRows[0].Name, "Zoe", StringComparison.Ordinal) || writtenRows[0].Age != 8 || !writtenRows[0].Active) { Console.Error.WriteLine("Source-generated XLSX write+read round trip produced an unexpected result."); @@ -47,7 +47,7 @@ await sheet.WriteRecordsAsync([new GeneratedAotModel { Name = "Zoe", Age = 8, Ac ReadOnlyMemory csv = Encoding.UTF8.GetBytes("Name,Age\r\nBob,42\r\n"); CsvReader csvReader = Excel.FromCsv(csv); - CsvReader.Enumerator csvRows = csvReader.GetEnumerator(); + CsvReader.Enumerator csvRows = csvReader.FirstSheet.GetEnumerator(); if (!csvRows.MoveNext()) { Console.Error.WriteLine("Raw CSV reader produced no header row."); @@ -72,9 +72,9 @@ private static bool EncryptedWorkbookReadsUnderAot() { string encrypted = Path.Combine(AppContext.BaseDirectory, "data", "encrypted", "agile-aes256-sha512.xlsx"); var options = new ExcelReaderOptions { Password = "hunter2" }; - using IExcelRowReader encryptedReader = Excel.Open(encrypted, options); + using IExcelWorkbook encryptedReader = Excel.Open(encrypted, options); int encryptedRowCount = 0; - foreach (Row encryptedRow in encryptedReader) + foreach (Row encryptedRow in encryptedReader.FirstSheet) { encryptedRowCount++; } diff --git a/tests/ExcelReader.Benchmarks/ColdStartBenchmark.cs b/tests/ExcelReader.Benchmarks/ColdStartBenchmark.cs index 617d4196..b6b54cdf 100644 --- a/tests/ExcelReader.Benchmarks/ColdStartBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/ColdStartBenchmark.cs @@ -57,7 +57,7 @@ public long TypedParseFirstUse() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += rec.Id; } @@ -90,7 +90,7 @@ public long FluentParseFirstUse() .Property(["Date"], ExcelCellReaders.DateTimeSerial, static (ref r, v) => r.Date = v) .Property(["Value"], ExcelCellReaders.Parsable, static (ref Record r, double v) => r.Value = v)); long acc = 0; - foreach (Record rec in parser.Parse(reader)) + foreach (Record rec in parser.Parse(reader.FirstSheet)) { acc += rec.Id; } @@ -105,7 +105,7 @@ public long FluentParseWithAttributeFallbackFirstUse() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static builder => builder .Property(["Id"], ExcelCellReaders.Parsable, static (ref Record r, int v) => r.Id = v)); long acc = 0; - foreach (Record rec in parser.Parse(reader)) + foreach (Record rec in parser.Parse(reader.FirstSheet)) { acc += rec.Id; } diff --git a/tests/ExcelReader.Benchmarks/Crypto/EncryptedWorkbookBenchmark.cs b/tests/ExcelReader.Benchmarks/Crypto/EncryptedWorkbookBenchmark.cs index df63dc21..07fd9f1e 100644 --- a/tests/ExcelReader.Benchmarks/Crypto/EncryptedWorkbookBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Crypto/EncryptedWorkbookBenchmark.cs @@ -33,7 +33,7 @@ private static ExcelReaderOptions Options(bool verifyIntegrity = false) }; } - private static long ReadAll(IExcelRowReader reader) + private static long ReadAll(IExcelSheet reader) { long acc = 0; using IExcelRowEnumerator rows = reader.GetEnumerator(); @@ -49,38 +49,38 @@ private static long ReadAll(IExcelRowReader reader) public long Plain_Stream() { using var fs = new FileStream(_plain, FileMode.Open, FileAccess.Read); - using IExcelRowReader reader = Excel.Open(fs, leaveOpen: true); - return ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(fs, leaveOpen: true); + return ReadAll(reader.FirstSheet); } [Benchmark] public long Encrypted_Stream() { using var fs = new FileStream(_encrypted, FileMode.Open, FileAccess.Read); - using IExcelRowReader reader = Excel.Open(fs, leaveOpen: true, Options()); - return ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(fs, leaveOpen: true, Options()); + return ReadAll(reader.FirstSheet); } [Benchmark] public long Encrypted_Stream_VerifyIntegrity() { using var fs = new FileStream(_encrypted, FileMode.Open, FileAccess.Read); - using IExcelRowReader reader = Excel.Open(fs, leaveOpen: true, Options(verifyIntegrity: true)); - return ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(fs, leaveOpen: true, Options(verifyIntegrity: true)); + return ReadAll(reader.FirstSheet); } [Benchmark] public long Encrypted_Memory() { - using IExcelRowReader reader = Excel.Open(_encryptedBytes.AsMemory(), Options()); - return ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(_encryptedBytes.AsMemory(), Options()); + return ReadAll(reader.FirstSheet); } [Benchmark] public int Encrypted_OpenOnly() { using var ms = new MemoryStream(_encryptedBytes, writable: false); - using IExcelRowReader reader = Excel.Open(ms, leaveOpen: true, Options()); + using IExcelWorkbook reader = Excel.Open(ms, leaveOpen: true, Options()); return reader.SheetCount; } private const string GeneratedPassword = "hunter2"; @@ -108,24 +108,24 @@ public async Task LargeSetupAsync() public long Large_Plain_Stream() { using var ms = new MemoryStream(_largePlainBytes, writable: false); - using IExcelRowReader reader = Excel.Open(ms, leaveOpen: true); - return ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(ms, leaveOpen: true); + return ReadAll(reader.FirstSheet); } [Benchmark] public long Large_Encrypted_Stream() { using var ms = new MemoryStream(_largeEncryptedBytes, writable: false); - using IExcelRowReader reader = Excel.Open(ms, leaveOpen: true, + using IExcelWorkbook reader = Excel.Open(ms, leaveOpen: true, new ExcelReaderOptions { Password = new ExcelPassword(GeneratedPassword) }); - return ReadAll(reader); + return ReadAll(reader.FirstSheet); } [Benchmark] public int Large_Encrypted_OpenOnly() { using var ms = new MemoryStream(_largeEncryptedBytes, writable: false); - using IExcelRowReader reader = Excel.Open(ms, leaveOpen: true, + using IExcelWorkbook reader = Excel.Open(ms, leaveOpen: true, new ExcelReaderOptions { Password = new(GeneratedPassword) }); return reader.SheetCount; } diff --git a/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs b/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs index 7e997196..7edec50b 100644 --- a/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Interop/ArrowConversionBenchmark.cs @@ -48,7 +48,7 @@ public async Task SetupAsync() public long CsvAllString() { using var reader = Excel.FromCsv(_csvAllString); - using RecordBatch batch = reader.ToArrowRecordBatch(AllStringSchema, headerRow: 0); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(AllStringSchema, headerRow: 0); return batch.Length; } @@ -56,7 +56,7 @@ public long CsvAllString() public long CsvTyped() { using var reader = Excel.FromCsv(_csvTyped); - using RecordBatch batch = reader.ToArrowRecordBatch(TypedSchema); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(TypedSchema); return batch.Length; } @@ -65,7 +65,7 @@ public long XlsbTyped() { using var ms = new MemoryStream(_xlsbTyped, writable: false); using var reader = Excel.FromXlsb(ms); - using RecordBatch batch = reader.ToArrowRecordBatch(TypedSchema); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(TypedSchema); return batch.Length; } @@ -73,7 +73,7 @@ public long XlsbTyped() public long CsvTypedWithInference() { using var reader = Excel.FromCsv(_csvTyped); - using RecordBatch batch = reader.ToArrowRecordBatch(); + using RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(); return batch.Length; } } diff --git a/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs b/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs index 13a70412..dd62d122 100644 --- a/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Interop/DataReaderBenchmark.cs @@ -8,7 +8,7 @@ namespace ExcelReader.Benchmarks [MemoryDiagnoser] [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "The interface dispatch is the thing being measured: SqlBulkCopy, DataTable.Load and " + - "Dapper all hold an IDataReader over an IExcelRowReader, so binding these to the concrete types " + + "Dapper all hold an IDataReader over an IExcelSheet, so binding these to the concrete types " + "would devirtualize calls a real consumer pays for and report a cost nobody actually sees.")] public class DataReaderBenchmark { @@ -24,7 +24,7 @@ public async Task SetupAsync() _xlsx = await WorkbookGenerator.BuildTypedAsync(Rows).ConfigureAwait(false); } - private IExcelRowReader OpenReader() + private IExcelWorkbook OpenReader() { return Excel.FromXlsx(new MemoryStream(_xlsx, writable: false), leaveOpen: false); } @@ -32,9 +32,9 @@ private IExcelRowReader OpenReader() [Benchmark(Baseline = true)] public long Baseline_RawRows() { - using IExcelRowReader reader = OpenReader(); + using IExcelWorkbook reader = OpenReader(); long acc = 0; - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { for (int i = 0; i < row.ColumnCount; i++) { @@ -47,8 +47,8 @@ public long Baseline_RawRows() [Benchmark] public long DataReader_GetValue() { - using IExcelRowReader reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader); + using IExcelWorkbook reader = OpenReader(); + using IDataReader data = new ExcelDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -63,8 +63,8 @@ public long DataReader_GetValue() [Benchmark] public long DataReader_TypedGetters() { - using IExcelRowReader reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader); + using IExcelWorkbook reader = OpenReader(); + using IDataReader data = new ExcelDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -79,8 +79,8 @@ public long DataReader_TypedGetters() [Benchmark] public long DataReader_GetBytes() { - using IExcelRowReader reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader); + using IExcelWorkbook reader = OpenReader(); + using IDataReader data = new ExcelDataReader(reader.FirstSheet); long acc = 0; while (data.Read()) { @@ -92,8 +92,8 @@ public long DataReader_GetBytes() [Benchmark] public int DataTable_Load() { - using IExcelRowReader reader = OpenReader(); - using IDataReader data = new ExcelDataReader(reader); + using IExcelWorkbook reader = OpenReader(); + using IDataReader data = new ExcelDataReader(reader.FirstSheet); var table = new DataTable(); table.Load(data); return table.Rows.Count; diff --git a/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs index 7faf4921..78201f19 100644 --- a/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/CsvParallelVsSepBenchmark.cs @@ -128,14 +128,14 @@ private async Task ReadExcelReaderAsync(int degreeOfParallelism) if (Corpus == ParallelCorpus.NarrowInt) { ExcelParser parser = ExcelParser.FromAttributes(); - foreach (NarrowRow row in parser.Parse(reader)) + foreach (NarrowRow row in parser.Parse(reader.FirstSheet)) { seqAcc += row.A; } return seqAcc; } ExcelParser wideParser = ExcelParser.FromAttributes(); - foreach (WideRow row in wideParser.Parse(reader)) + foreach (WideRow row in wideParser.Parse(reader.FirstSheet)) { seqAcc += row.Units; } diff --git a/tests/ExcelReader.Benchmarks/Parse/CsvParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/CsvParseBenchmark.cs index 3242981c..78403a8a 100644 --- a/tests/ExcelReader.Benchmarks/Parse/CsvParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/CsvParseBenchmark.cs @@ -41,7 +41,7 @@ public long ExcelParserSync() using var ms = new MemoryStream(_csv, writable: false); using var reader = Excel.FromCsv(ms); long acc = 0; - foreach (CsvRecord rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (CsvRecord rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -54,7 +54,7 @@ public async Task ExcelParserAsync() await using var ms = new MemoryStream(_csv, writable: false); await using var reader = Excel.FromCsv(ms); long acc = 0; - await foreach (CsvRecord rec in ExcelParser.FromAttributes().Parse(reader)) + await foreach (CsvRecord rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } diff --git a/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs index 0bd77912..c7d1b1c2 100644 --- a/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Parse/ParseBenchmark.cs @@ -47,7 +47,7 @@ public long ExcelParserSync() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -60,7 +60,7 @@ public long ExcelParserSyncSharedStrings() using var ms = new MemoryStream(_workbookSharedStrings, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -73,7 +73,7 @@ public long ExcelParserStructSync() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (RecordStruct rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (RecordStruct rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -86,7 +86,7 @@ public long RefStructModelSync() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (RecordNamedRef rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (RecordNamedRef rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -99,7 +99,7 @@ public async Task ExcelParserAsync() await using var ms = new MemoryStream(_workbook, writable: false); await using var reader = await Excel.FromXlsxAsync(ms); long acc = 0; - await foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + await foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -113,7 +113,7 @@ public long ExcelParserXlsbSync() using var ms = new MemoryStream(_xlsbWorkbook, writable: false); using var reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } @@ -126,7 +126,7 @@ public async Task ExcelParserXlsbAsync() await using var ms = new MemoryStream(_xlsbWorkbook, writable: false); await using var reader = await Excel.FromXlsbAsync(ms); long acc = 0; - await foreach (Record rec in ExcelParser.FromAttributes().Parse(reader)) + await foreach (Record rec in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(rec); } diff --git a/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs index c51b0224..12a7a87c 100644 --- a/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/CsvReadBenchmark.cs @@ -32,7 +32,7 @@ public long ExcelReader() using var ms = new MemoryStream(_csv, writable: false); using var reader = Excel.FromCsv(ms); long acc = 0; - foreach (var row in reader) + foreach (var row in reader.FirstSheet) { acc += AccumulateRow(row); } @@ -45,7 +45,7 @@ public long ExcelReaderWide() using var ms = new MemoryStream(_csvWide, writable: false); using var reader = Excel.FromCsv(ms); long acc = 0; - foreach (var row in reader) + foreach (var row in reader.FirstSheet) { for (int i = 0; i < row.ColumnCount; i++) { @@ -60,7 +60,7 @@ public async Task ExcelReaderAsync() { await using var ms = new MemoryStream(_csv, writable: false); await using var reader = Excel.FromCsv(ms); - await using var e = reader.GetAsyncEnumerator(); + await using var e = reader.FirstSheet.GetAsyncEnumerator(); long acc = 0; while (await e.MoveNextAsync()) { diff --git a/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs index 18ff4652..82aff6fa 100644 --- a/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/ReadBenchmark.cs @@ -28,7 +28,7 @@ public long ExcelReader() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -37,7 +37,7 @@ public async Task ExcelReaderAsync() { await using var ms = new MemoryStream(_workbook, writable: false); await using var reader = await Excel.FromXlsxAsync(ms); - await using var e = reader.GetAsyncEnumerator(); + await using var e = reader.FirstSheet.GetAsyncEnumerator(); long acc = 0; while (await e.MoveNextAsync()) { acc += AccumulateRow(e.Current); } return acc; @@ -49,7 +49,7 @@ public long ExcelReaderXlsb() using var ms = new MemoryStream(_xlsbWorkbook, writable: false); using var reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -58,7 +58,7 @@ public async Task ExcelReaderXlsbAsync() { await using var ms = new MemoryStream(_xlsbWorkbook, writable: false); await using var reader = await Excel.FromXlsbAsync(ms); - await using var e = reader.GetAsyncEnumerator(); + await using var e = reader.FirstSheet.GetAsyncEnumerator(); long acc = 0; while (await e.MoveNextAsync()) { acc += AccumulateRow(e.Current); } return acc; @@ -70,7 +70,7 @@ public long ExcelReaderMaterialized() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } diff --git a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs index c5068d03..56a402ce 100644 --- a/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/StringHeavyReadBenchmark.cs @@ -48,7 +48,7 @@ public long Xlsx_ExcelReader() using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader)); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -58,7 +58,7 @@ public long Xlsx_ExcelReader_Prefetch() using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Prefetch)); using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -76,7 +76,7 @@ public long Xlsx_ExcelReader_Materialized() using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Materialized)); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -86,7 +86,7 @@ public long Xlsx_ExcelReader_Materialized_Interned() using MemoryStream ms = Open(_xlsx, nameof(Xlsx_ExcelReader_Materialized_Interned)); using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _internOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -97,7 +97,7 @@ public long Xlsb_ExcelReader() using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader)); using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -107,7 +107,7 @@ public long Xlsb_ExcelReader_Prefetch() using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Prefetch)); using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -125,7 +125,7 @@ public long Xlsb_ExcelReader_Materialized() using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Materialized)); using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -135,7 +135,7 @@ public long Xlsb_ExcelReader_Materialized_Interned() using MemoryStream ms = Open(_xlsb, nameof(Xlsb_ExcelReader_Materialized_Interned)); using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _internOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -148,7 +148,7 @@ public long Xlsb_ExcelReader_Memory() } using XlsbWorkbook reader = Excel.FromXlsb(new ReadOnlyMemory(_xlsb)); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } } diff --git a/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs index c244211a..0dd985a3 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsReadBenchmark.cs @@ -28,7 +28,7 @@ public long ExcelReader() using var ms = new MemoryStream(_workbook, writable: false); using var reader = Excel.FromXls(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -37,7 +37,7 @@ public async Task ExcelReaderAsync() { await using var ms = new MemoryStream(_workbook, writable: false); await using var reader = await Excel.FromXlsAsync(ms); - await using var e = reader.GetAsyncEnumerator(); + await using var e = reader.FirstSheet.GetAsyncEnumerator(); long acc = 0; while (await e.MoveNextAsync()) { acc += AccumulateRow(e.Current); } return acc; diff --git a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs index ee513e6d..36782f49 100644 --- a/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Read/XlsxSharedStringHotPathBenchmark.cs @@ -57,7 +57,7 @@ private static long ReadAll(byte[] workbook, ExcelReaderOptions? options) using var ms = new MemoryStream(workbook, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -65,7 +65,7 @@ private static bool Prologue(byte[] workbook) { using var ms = new MemoryStream(workbook, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator rows = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator rows = reader.FirstSheet.GetEnumerator(); return rows.MoveNext(); } diff --git a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs index 6e704ac9..d8a2e82d 100644 --- a/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/RealData/RealDataReadBenchmark.cs @@ -42,7 +42,7 @@ public long Xlsx_ExcelReader() using MemoryStream ms = new(_xlsx, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -60,7 +60,7 @@ public long Xlsx_ExcelReader_Materialized() using MemoryStream ms = new(_xlsx, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -70,7 +70,7 @@ public long Xlsx_ExcelReader_Prefetch() using MemoryStream ms = new(_xlsx, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -79,7 +79,7 @@ public long Xlsx_ExcelReader_Memory() { using XlsxWorkbook reader = Excel.FromXlsx(_xlsx.AsMemory()); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -88,7 +88,7 @@ public long Xlsx_ExcelReader_Memory_Prefetch() { using XlsxWorkbook reader = Excel.FromXlsx(_xlsx.AsMemory(), options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -99,7 +99,7 @@ public long Xlsm_ExcelReader() using MemoryStream ms = new(_xlsm, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -117,7 +117,7 @@ public long Xlsm_ExcelReader_Materialized() using MemoryStream ms = new(_xlsm, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -127,7 +127,7 @@ public long Xlsm_ExcelReader_Prefetch() using MemoryStream ms = new(_xlsm, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms, options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -136,7 +136,7 @@ public long Xlsm_ExcelReader_Memory() { using XlsxWorkbook reader = Excel.FromXlsx(_xlsm.AsMemory()); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -145,7 +145,7 @@ public long Xlsm_ExcelReader_Memory_Prefetch() { using XlsxWorkbook reader = Excel.FromXlsx(_xlsm.AsMemory(), options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -156,7 +156,7 @@ public long Xlsb_ExcelReader() using MemoryStream ms = new(_xlsb, writable: false); using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -174,7 +174,7 @@ public long Xlsb_ExcelReader_Materialized() using MemoryStream ms = new(_xlsb, writable: false); using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -184,7 +184,7 @@ public long Xlsb_ExcelReader_Prefetch() using MemoryStream ms = new(_xlsb, writable: false); using XlsbWorkbook reader = Excel.FromXlsb(ms, options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -193,7 +193,7 @@ public long Xlsb_ExcelReader_Memory() { using XlsbWorkbook reader = Excel.FromXlsb(_xlsb.AsMemory()); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -202,7 +202,7 @@ public long Xlsb_ExcelReader_Memory_Prefetch() { using XlsbWorkbook reader = Excel.FromXlsb(_xlsb.AsMemory(), options: _prefetchOptions); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -213,7 +213,7 @@ public long Xls_ExcelReader() using MemoryStream ms = new(_xls, writable: false); using XlsWorkbook reader = Excel.FromXls(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -231,7 +231,7 @@ public long Xls_ExcelReader_Materialized() using MemoryStream ms = new(_xls, writable: false); using XlsWorkbook reader = Excel.FromXls(ms); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRowMaterialized(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRowMaterialized(row); } return acc; } @@ -240,7 +240,7 @@ public long Xls_ExcelReader_Memory() { using XlsWorkbook reader = Excel.FromXls(_xls.AsMemory()); long acc = 0; - foreach (Row row in reader) { acc += AccumulateRow(row); } + foreach (Row row in reader.FirstSheet) { acc += AccumulateRow(row); } return acc; } @@ -251,7 +251,7 @@ public long Csv_ExcelReader() using MemoryStream ms = new(_csv, writable: false); using CsvReader reader = Excel.FromCsv(ms); long acc = 0; - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { foreach (RowCell rowCell in row.Cells) { @@ -285,7 +285,7 @@ public long Csv_ExcelReader_Materialized() using MemoryStream ms = new(_csv, writable: false); using CsvReader reader = Excel.FromCsv(ms); long acc = 0; - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { foreach (RowCell rowCell in row.Cells) { @@ -300,7 +300,7 @@ public long Csv_ExcelReader_Memory() { using CsvReader reader = Excel.FromCsv(_csv.AsMemory()); long acc = 0; - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { foreach (RowCell rowCell in row.Cells) { diff --git a/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs b/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs index ef7441a1..abc37afb 100644 --- a/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/RealData/RealDataTypedParseBenchmark.cs @@ -80,7 +80,7 @@ public long Xlsx_ExcelParser() using MemoryStream ms = new(_xlsx, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); long acc = 0; - foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader)) + foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(row); } @@ -93,7 +93,7 @@ public long Xlsb_ExcelParser() using MemoryStream ms = new(_xlsb, writable: false); using XlsbWorkbook reader = Excel.FromXlsb(ms); long acc = 0; - foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader)) + foreach (FullRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { acc += Accumulate(row); } diff --git a/tests/ExcelReader.Fuzz/Harnesses.cs b/tests/ExcelReader.Fuzz/Harnesses.cs index b9e8e7dc..9b98fa7a 100644 --- a/tests/ExcelReader.Fuzz/Harnesses.cs +++ b/tests/ExcelReader.Fuzz/Harnesses.cs @@ -114,7 +114,7 @@ internal static void Encrypted(ReadOnlySpan data) byte[] bytes = data.ToArray(); FuzzOracle.Guard(() => { - using IExcelRowReader reader = Excel.Open(bytes, EncryptedLimits); + using IExcelWorkbook reader = Excel.Open(bytes, EncryptedLimits); DrainAllSheets(reader); }); } @@ -122,13 +122,12 @@ internal static void Encrypted(ReadOnlySpan data) internal static int OpenEncryptedSeedForSelfCheck(ReadOnlySpan data) { byte[] bytes = data.ToArray(); - using IExcelRowReader reader = Excel.Open(bytes, EncryptedLimits); + using IExcelWorkbook reader = Excel.Open(bytes, EncryptedLimits); int rows = 0; int sheets = reader.SheetCount; for (int i = 0; i < sheets; i++) { - reader.MoveToSheet(i); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(i).GetEnumerator(); while (e.MoveNext()) { rows++; @@ -144,7 +143,7 @@ internal static void Csv(ReadOnlySpan data) { using var ms = new MemoryStream(bytes, writable: false); using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, CsvLimits); - DrainRows(reader); + DrainRows(reader.FirstSheet); }); } @@ -156,7 +155,7 @@ internal static void CsvSniff(ReadOnlySpan data) CsvDialect dialect = CsvSniffer.Detect(bytes); using var ms = new MemoryStream(bytes, writable: false); using CsvReader reader = Excel.FromCsv(ms, leaveOpen: true, CsvLimits.WithDialect(dialect)); - DrainRows(reader); + DrainRows(reader.FirstSheet); }); } @@ -226,7 +225,7 @@ private static List ParseSequential(byte[] bytes) { using CsvReader reader = Excel.FromCsv(bytes, CsvLimits); var rows = new List(); - foreach (FuzzRow row in ExcelParser.FromAttributes().Parse(reader)) + foreach (FuzzRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { rows.Add(Render(row)); } @@ -293,8 +292,8 @@ internal static void AssertSameRowsSelfCheck() private static void CompareReaders( byte[] bytes, - Func left, - Func right, + Func left, + Func right, string leftName, string rightName) { @@ -353,7 +352,7 @@ private static void AssertSameRows(List left, List right, string } } - private static List RenderAllSheets(IExcelRowReader reader) + private static List RenderAllSheets(IExcelWorkbook reader) { using (reader) { @@ -361,9 +360,8 @@ private static List RenderAllSheets(IExcelRowReader reader) int sheets = reader.SheetCount; for (int i = 0; i < sheets; i++) { - reader.MoveToSheet(i); rows.Add(string.Create(CultureInfo.InvariantCulture, $"#sheet{i}")); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(i).GetEnumerator(); while (e.MoveNext()) { rows.Add(RenderRow(e.Current, reader.IsDate1904)); @@ -385,17 +383,16 @@ private static string RenderRow(Row row, bool isDate1904) return sb.ToString(); } - private static void DrainAllSheets(IExcelRowReader reader) + private static void DrainAllSheets(IExcelWorkbook reader) { int sheets = reader.SheetCount; for (int i = 0; i < sheets; i++) { - reader.MoveToSheet(i); - DrainRows(reader); + DrainRows(reader.SheetAt(i)); } } - private static void DrainRows(IExcelRowReader reader) + private static void DrainRows(IExcelSheet reader) { using IExcelRowEnumerator rows = reader.GetEnumerator(); while (rows.MoveNext()) @@ -404,7 +401,7 @@ private static void DrainRows(IExcelRowReader reader) } } - private static void DrainRows(CsvReader reader) + private static void DrainRows(CsvSheet reader) { using CsvReader.Enumerator rows = reader.GetEnumerator(); while (rows.MoveNext()) diff --git a/tests/ExcelReader.Tests/FuzzTests.cs b/tests/ExcelReader.Tests/FuzzTests.cs index 36718227..e48a6525 100644 --- a/tests/ExcelReader.Tests/FuzzTests.cs +++ b/tests/ExcelReader.Tests/FuzzTests.cs @@ -149,11 +149,10 @@ private static int FuzzFormat(byte[] seed, string format) private static void OpenAndDrain(byte[] bytes) { using var ms = new MemoryStream(bytes); - using IExcelRowReader reader = Excel.Open(ms); + using IExcelWorkbook reader = Excel.Open(ms); for (int s = 0; s < reader.SheetCount; s++) { - reader.MoveToSheet(s); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(s).GetEnumerator(); while (e.MoveNext()) { for (int c = 0; c < e.Current.ColumnCount; c++) From 8ec6147b3b5f404222eb442bea5c7aeb209427c5 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:43:14 -0300 Subject: [PATCH 07/49] test(core): read through sheets in the reader tests Move the tests directly under tests/ExcelReader.Tests/Reader off the v5 reader-with-a-current-sheet members: enumerators, parsers, schema inference and ExcelDataReader take a sheet, navigation tests assert the v6 workbook and sheet equivalents. Co-Authored-By: Claude Sonnet 5.5 --- .../Reader/AsyncEnumerationTests.cs | 4 +- .../Reader/CellVariantTests.cs | 40 +++++----- .../Reader/ConcurrencyContractTests.cs | 4 +- .../Reader/ConcurrentSheetTests.cs | 27 +++---- .../Reader/DialectRobustnessTests.cs | 10 +-- .../Reader/EmptyRowParityTests.cs | 10 +-- .../Reader/ExcelDataReaderTests.cs | 80 +++++++++---------- .../Reader/ExcelOpenAndOleErrorTests.cs | 20 ++--- .../ExcelReader.Tests/Reader/FastDateTests.cs | 4 +- .../Reader/MemorySourceParityTests.cs | 20 +++-- .../Reader/MidStreamCancellationTests.cs | 8 +- .../Reader/MultiSheetTests.cs | 59 +++++++------- .../Reader/ReaderLimitTests.cs | 20 ++--- tests/ExcelReader.Tests/Reader/SampleTest.cs | 16 ++-- .../Reader/SchemaInferenceTests.cs | 20 ++--- .../Reader/SheetVisibilityTests.cs | 55 +++++++------ .../Reader/SourceParityTests.cs | 70 ++++++++-------- .../Reader/SyncAsyncParityTests.cs | 32 ++++---- .../Reader/Utf8StringCacheTests.cs | 8 +- 19 files changed, 251 insertions(+), 256 deletions(-) diff --git a/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs b/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs index d6a32223..be1c949e 100644 --- a/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs +++ b/tests/ExcelReader.Tests/Reader/AsyncEnumerationTests.cs @@ -18,7 +18,7 @@ public async Task XlsxAsyncEnumerationNeverReadsSynchronously() await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, ct: ct); stream.AllowSyncReads = false; - Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.GetAsyncEnumerator(ct))); + Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.FirstSheet.GetAsyncEnumerator(ct))); } [Fact] @@ -30,7 +30,7 @@ public async Task XlsbAsyncEnumerationNeverReadsSynchronously() await using XlsbWorkbook reader = await Excel.FromXlsbAsync(stream, ct: ct); stream.AllowSyncReads = false; - Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.GetAsyncEnumerator(ct))); + Assert.Equal(["a", "b"], await ReadFirstColumnAsync(reader.FirstSheet.GetAsyncEnumerator(ct))); } private static async Task> ReadFirstColumnAsync(IExcelRowEnumerator e) diff --git a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs index cd244591..08ee5612 100644 --- a/tests/ExcelReader.Tests/Reader/CellVariantTests.cs +++ b/tests/ExcelReader.Tests/Reader/CellVariantTests.cs @@ -10,7 +10,7 @@ public async Task BooleanCellHasBooleanType() { await using var ms = await TypedWorkbook.BuildAsync([true, false]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal("1", e.Current[0].GetString()); @@ -24,7 +24,7 @@ public void ErrorCellHasErrorType() using var ms = WorkbookBuilder.Build( """#DIV/0!"""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Error, e.Current[0].Type); Assert.Equal("#DIV/0!", e.Current[0].GetString()); @@ -36,7 +36,7 @@ public void FormulaCellHasFormulaType() using var ms = WorkbookBuilder.Build( """Hello"""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Formula, e.Current[0].Type); Assert.Equal("Hello", e.Current[0].GetString()); @@ -48,7 +48,7 @@ public void TryGetDoubleRejectsCommaDecimalInsteadOfMisreadingIt() using var ms = WorkbookBuilder.Build( """1,51.5"""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.False(e.Current[0].TryGetDouble(out double ambiguous)); Assert.Equal(0.0, ambiguous); @@ -61,7 +61,7 @@ public async Task EmptyWorksheetYieldsNoRows() { await using var ms = await TypedWorkbook.BuildAsync(); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var enumerator = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using var enumerator = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); int count = 0; while (await enumerator.MoveNextAsync()) { @@ -75,7 +75,7 @@ public void SelfClosingRowYieldsZeroColumnCount() { using var ms = WorkbookBuilder.Build(""""""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(0, e.Current.ColumnCount); } @@ -87,7 +87,7 @@ public void DecodesQuotAndAposEntities() """01""", sharedStrings: "say "hi"it's"); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.Equal("say \"hi\"", row[0].GetString()); @@ -101,7 +101,7 @@ public void DecodesHexNumericEntity() """0""", sharedStrings: "Az"); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Az", e.Current[0].GetString()); } @@ -113,7 +113,7 @@ public void RichTextRunsConcatenated() """0""", sharedStrings: "Hello World"); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Hello World", e.Current[0].GetString()); } @@ -127,7 +127,7 @@ public void DateFormatLettersInsideQuotesIsNotDate() using var ms = WorkbookBuilder.Build( """45292""", styles: styles); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); } @@ -141,7 +141,7 @@ public void DateFormatLettersInsideBracketsIsNotDate() using var ms = WorkbookBuilder.Build( """45292""", styles: styles); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); } @@ -155,7 +155,7 @@ public void DateFormatBackslashEscapedLetterIsNotDate() using var ms = WorkbookBuilder.Build( """45292""", styles: styles); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); } @@ -165,7 +165,7 @@ public async Task TryGetDateTimeOnInlineStringReturnsFalse() { await using var ms = await TypedWorkbook.BuildAsync(["not-a-date"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.False(e.Current[0].TryGetDateTime(out _)); } @@ -175,7 +175,7 @@ public async Task TryGetDateTimeOutOfRangeSerialReturnsFalse() { await using var ms = await TypedWorkbook.BuildAsync([3000000]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.False(e.Current[0].TryGetDateTime(out _)); } @@ -207,7 +207,7 @@ public async Task LargeColumnReferenceAAIsColumn26() { await using var ms = await TypedWorkbook.BuildAsync([new Gap(26), 99]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Empty, e.Current[0].Type); Assert.Equal(CellType.Number, e.Current[26].Type); @@ -220,7 +220,7 @@ public async Task TryParseDoubleSucceeds() { await using var ms = await TypedWorkbook.BuildAsync([3.14]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.True(e.Current[0].TryParse(CultureInfo.InvariantCulture, out double d)); Assert.Equal(3.14, d); @@ -231,7 +231,7 @@ public async Task TryParseLongSucceeds() { await using var ms = await TypedWorkbook.BuildAsync([long.MaxValue]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e = reader.GetEnumerator(); + await using var e = reader.FirstSheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.True(e.Current[0].TryParse(null, out long l)); Assert.Equal(long.MaxValue, l); @@ -248,7 +248,7 @@ public void Date1904WorkbookReadsCorrectDates() date1904: true); using var reader = Excel.FromXlsx(ms); Assert.True(reader.IsDate1904); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.Equal(CellType.Date, row[0].Type); @@ -275,7 +275,7 @@ public void Excel1900SerialsOneToSixtyMapToExcelCalendar() """15960""", styles: styles); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(out DateTime first)); @@ -291,7 +291,7 @@ public void BinaryNumberOutsideDecimalRangeDoesNotThrow() { using var ms = WorkbookBuilder.Build("""1E+30"""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.False(e.Current[0].TryParse(null, out decimal _)); diff --git a/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs index b63e5dff..5b01c829 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrencyContractTests.cs @@ -15,7 +15,7 @@ public Task IndependentXlsxWorkbooksOnSeparateThreadsDoNotInterfere() string expected = $"value-{i}"; using MemoryStream ms = WorkbookBuilder.Build($"""{expected}"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); })); @@ -43,7 +43,7 @@ public Task IndependentXlsxWorkbookWritersOnSeparateThreadsDoNotInterfere() } ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); })); diff --git a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs index 3682dafa..e7507436 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs @@ -99,7 +99,7 @@ private static byte[] Build(Format format) return buffer.ToArray(); } - private IExcelRowReader Open(Format format, Source source, byte[] bytes) + private IExcelWorkbook Open(Format format, Source source, byte[] bytes) { if (source == Source.Memory) { @@ -126,13 +126,12 @@ private static List Drain(IExcelRowEnumerator e) return lines; } - private static List[] ReadSequentially(IExcelRowReader reader) + private static List[] ReadSequentially(IExcelWorkbook reader) { List[] sheets = new List[reader.SheetCount]; for (int s = 0; s < sheets.Length; s++) { - reader.MoveToSheet(s); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(s).GetEnumerator(); sheets[s] = Drain(e); } return sheets; @@ -144,19 +143,18 @@ public void Sheets_Read_In_Parallel_Match_A_Sequential_Read(Format format, Sourc { byte[] bytes = Build(format); List[] expected; - using (IExcelRowReader reference = Excel.Open(bytes)) + using (IExcelWorkbook reference = Excel.Open(bytes)) { expected = ReadSequentially(reference); } Assert.Equal(Sheets, expected.Length); Assert.All(expected, sheet => Assert.Equal(Rows, sheet.Count)); - using IExcelRowReader reader = Open(format, source, bytes); + using IExcelWorkbook reader = Open(format, source, bytes); IExcelRowEnumerator[] enumerators = new IExcelRowEnumerator[Sheets]; for (int s = 0; s < Sheets; s++) { - reader.MoveToSheet(s); - enumerators[s] = reader.GetEnumerator(); + enumerators[s] = reader.SheetAt(s).GetEnumerator(); } List[] actual = new List[Sheets]; @@ -179,17 +177,16 @@ public async Task Sheets_Read_In_Parallel_Asynchronously_Match_A_Sequential_Read CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = Build(format); List[] expected; - using (IExcelRowReader reference = Excel.Open(bytes)) + using (IExcelWorkbook reference = Excel.Open(bytes)) { expected = ReadSequentially(reference); } - using IExcelRowReader reader = Open(format, source, bytes); + using IExcelWorkbook reader = Open(format, source, bytes); IExcelRowEnumerator[] enumerators = new IExcelRowEnumerator[Sheets]; for (int s = 0; s < Sheets; s++) { - reader.MoveToSheet(s); - enumerators[s] = reader.GetAsyncEnumerator(ct); + enumerators[s] = reader.SheetAt(s).GetAsyncEnumerator(ct); } Task>[] tasks = new Task>[Sheets]; @@ -225,14 +222,14 @@ public async Task Sheets_Read_In_Parallel_Asynchronously_Match_A_Sequential_Read public void Threads_Reading_One_Sheet_Get_The_Same_String_Instances(Format format, Source source) { byte[] bytes = Build(format); - using IExcelRowReader reader = Open(format, source, bytes); - reader.MoveToSheet(0); + using IExcelWorkbook reader = Open(format, source, bytes); + IExcelSheet sheet = reader.FirstSheet; const int Threads = 8; IExcelRowEnumerator[] enumerators = new IExcelRowEnumerator[Threads]; for (int t = 0; t < Threads; t++) { - enumerators[t] = reader.GetEnumerator(); + enumerators[t] = sheet.GetEnumerator(); } string[][] thirdColumn = new string[Threads][]; diff --git a/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs b/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs index 97451347..9c1bd18d 100644 --- a/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs +++ b/tests/ExcelReader.Tests/Reader/DialectRobustnessTests.cs @@ -75,9 +75,9 @@ public void SingleQuotedWorkbookAttributesAreRead() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms); - Assert.Equal("Data", reader.SheetName); + Assert.Equal("Data", reader.FirstSheet.Name); Assert.True(reader.IsDate1904); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); } @@ -89,7 +89,7 @@ public void SingleQuotedStyleAttributesDetectDate() """45658""", styles: ""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -103,7 +103,7 @@ public void CorruptSharedStringIndexYieldsEmptyNotFirstString() """x0""", sharedStrings: "firstsecond"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("", e.Current[0].GetString()); @@ -117,7 +117,7 @@ public void EmptySharedStringIndexYieldsEmpty() """""", sharedStrings: "first"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/EmptyRowParityTests.cs b/tests/ExcelReader.Tests/Reader/EmptyRowParityTests.cs index 4f1a172d..0f162a7c 100644 --- a/tests/ExcelReader.Tests/Reader/EmptyRowParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/EmptyRowParityTests.cs @@ -51,7 +51,7 @@ public void Xlsx_Yields_Rows_Written_Without_Cells() } using var reader = Excel.FromXlsx(ms.ToArray()); - Assert.Equal(["a", null, "b", null], FirstCells(reader.GetEnumerator())); + Assert.Equal(["a", null, "b", null], FirstCells(reader.FirstSheet.GetEnumerator())); } [Fact] @@ -64,7 +64,7 @@ public void Xlsb_Yields_Rows_Written_Without_Cells() } using var reader = Excel.FromXlsb(ms.ToArray()); - Assert.Equal(["a", null, "b", null], FirstCells(reader.GetEnumerator())); + Assert.Equal(["a", null, "b", null], FirstCells(reader.FirstSheet.GetEnumerator())); } [Fact] @@ -77,7 +77,7 @@ public void Xls_Yields_Rows_Written_Without_Cells() } using var reader = Excel.FromXls(ms.ToArray()); - Assert.Equal(["a", null, "b", null], FirstCells(reader.GetEnumerator())); + Assert.Equal(["a", null, "b", null], FirstCells(reader.FirstSheet.GetEnumerator())); } [Fact] @@ -93,7 +93,7 @@ public void Xls_Yields_A_Row_Record_Without_Cells_In_Row_Order() (LabelRecord, XlsWorkbookBuilder.RawLabel(2, 0, "b"))); using var reader = Excel.FromXls(ms); - Assert.Equal(["a", null, "b", null], FirstCells(reader.GetEnumerator())); + Assert.Equal(["a", null, "b", null], FirstCells(reader.FirstSheet.GetEnumerator())); } [Fact] @@ -107,7 +107,7 @@ public void Xls_Does_Not_Repeat_A_Row_Whose_Row_Record_Follows_Its_Cells() (RowRecord, XlsWorkbookBuilder.RawRowOnly(1))); using var reader = Excel.FromXls(ms); - Assert.Equal(["a", "b"], FirstCells(reader.GetEnumerator())); + Assert.Equal(["a", "b"], FirstCells(reader.FirstSheet.GetEnumerator())); } } } diff --git a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs index 572c588d..bc4c7f62 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/ExcelDataReaderTests.cs @@ -17,8 +17,8 @@ public async Task HeaderRowFixesNamesAndFieldCount() await using var ms = await TypedWorkbook.BuildAsync( ["Id", "Name"], [1, "alice"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.Equal(2, data.FieldCount); Assert.Equal("Id", data.GetName(0)); @@ -35,8 +35,8 @@ public async Task ReadWalksEveryDataRowAfterTheHeader() [1], [2], [3]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); var seen = new List(); while (data.Read()) @@ -52,8 +52,8 @@ public async Task GetValueMapsEachCellTypeToItsClrType() await using var ms = await TypedWorkbook.BuildAsync( ["Text", "Number", "Flag", "When", "Blank"], ["hi", 42, true, new DateTime(2024, 1, 1), null]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal("hi", data.GetValue(0)); @@ -68,8 +68,8 @@ public async Task GetValueMapsEachCellTypeToItsClrType() public async Task NoHeaderSynthesizesColumnNamesFromFirstRowWidth() { await using var ms = await TypedWorkbook.BuildAsync([1, 2, 3]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader, headerRow: 0); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet, headerRow: 0); Assert.Equal(3, data.FieldCount); Assert.Equal("Column0", data.GetName(0)); @@ -82,8 +82,8 @@ public async Task NoHeaderSynthesizesColumnNamesFromFirstRowWidth() public async Task HeaderRowBeyondSheetSizeIsAnEmptyResultSet() { await using var ms = await TypedWorkbook.BuildAsync(["Id"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader, headerRow: 5); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet, headerRow: 5); Assert.Equal(0, data.FieldCount); Assert.False(data.Read()); @@ -93,8 +93,8 @@ public async Task HeaderRowBeyondSheetSizeIsAnEmptyResultSet() public async Task GetOrdinalForUnknownNameThrows() { await using var ms = await TypedWorkbook.BuildAsync(["Id"], [1]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.Throws(() => data.GetOrdinal("DoesNotExist")); } @@ -103,8 +103,8 @@ public async Task GetOrdinalForUnknownNameThrows() public async Task GetValueBeforeReadThrows() { await using var ms = await TypedWorkbook.BuildAsync(["Id"], [1]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.Throws(() => data.GetValue(0)); } @@ -115,8 +115,8 @@ public async Task NextResultAlwaysReturnsFalse() await using var ms = await TypedWorkbook.BuildMultiSheetAsync( ("A", [[1]]), ("B", [[2]])); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.False(data.NextResult()); } @@ -125,8 +125,8 @@ public async Task NextResultAlwaysReturnsFalse() public async Task GetBytesReturnsTheCellsUtf8BytesWindowedByOffsetAndLength() { await using var ms = await TypedWorkbook.BuildAsync(["Text"], ["héllo"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] expected = Encoding.UTF8.GetBytes("héllo"); @@ -147,8 +147,8 @@ public async Task GetBytesReturnsTheCellsUtf8BytesWindowedByOffsetAndLength() public async Task GetBytesFormatsANumericCellInsteadOfReturningNothing() { await using var ms = await TypedWorkbook.BuildAsync(["Number"], [42]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] buffer = new byte[8]; @@ -160,8 +160,8 @@ public async Task GetBytesFormatsANumericCellInsteadOfReturningNothing() public async Task GetBytesOnADbNullCellReturnsZero() { await using var ms = await TypedWorkbook.BuildAsync(["Blank"], [null]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(0, data.GetBytes(0, 0, null, 0, 0)); @@ -173,8 +173,8 @@ public async Task GetBytesOnADbNullCellReturnsZero() public async Task GetCharsReturnsTheCellsTextWindowedByOffsetAndLength() { await using var ms = await TypedWorkbook.BuildAsync(["Text"], ["hello world"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(11, data.GetChars(0, 0, null, 0, 0)); @@ -206,7 +206,7 @@ public async Task GetBytesFormatsAnXlsbNumericCellFromItsBinaryValue() ms.Position = 0; using XlsbWorkbook reader = Excel.FromXlsb(ms); - using var data = new ExcelDataReader(reader); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); byte[] buffer = new byte[8]; @@ -218,8 +218,8 @@ public async Task GetBytesFormatsAnXlsbNumericCellFromItsBinaryValue() public async Task TypedGettersParseANumericCellIntoEveryIntegralAndFloatingType() { await using var ms = await TypedWorkbook.BuildAsync(["Number"], [42]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal((byte)42, data.GetByte(0)); @@ -237,8 +237,8 @@ public async Task GetBooleanAndGetDateTimeReadTheirOwnCellTypesAndRejectText() await using var ms = await TypedWorkbook.BuildAsync( ["Flag", "When", "Text"], [true, new DateTime(2024, 3, 7), "nope"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.True(data.GetBoolean(0)); @@ -254,8 +254,8 @@ public async Task GetGuidParsesATextCellAndRejectsOneThatIsNotAGuid() await using var ms = await TypedWorkbook.BuildAsync( ["Id", "Text"], [guid.ToString(), "nope"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(guid, data.GetGuid(0)); @@ -268,8 +268,8 @@ public async Task GetCharTakesTheFirstCharacterAndRejectsAnEmptyCell() await using var ms = await TypedWorkbook.BuildAsync( ["Text", "Blank"], ["hello", null]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal('h', data.GetChar(0)); @@ -282,8 +282,8 @@ public async Task GetFieldTypeFollowsTheCurrentRowsOwnCellTypes() await using var ms = await TypedWorkbook.BuildAsync( ["Text", "Number", "Flag", "When", "Blank"], ["hi", 42, true, new DateTime(2024, 1, 1), null]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(typeof(string), data.GetFieldType(0)); @@ -298,8 +298,8 @@ public async Task GetFieldTypeFollowsTheCurrentRowsOwnCellTypes() public async Task IndexersAndTheFixedAdoMembersReportTheDocumentedValues() { await using var ms = await TypedWorkbook.BuildAsync(["Id", "Name"], [1, "alice"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); Assert.True(data.Read()); Assert.Equal(1.0, data[0]); @@ -316,8 +316,8 @@ public async Task DataTableLoadPopulatesFromTheSheet() ["Id", "Name"], [1, "alice"], [2, "bob"]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - using var data = new ExcelDataReader(reader); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + using var data = new ExcelDataReader(reader.FirstSheet); var table = new DataTable(); table.Load(data); @@ -353,7 +353,7 @@ public void AFailedConstructorDoesNotKeepTheReaderAlive() XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxCellBytes = 64 }); Assert.Throws(() => { - using ExcelDataReader data = new(reader); + using ExcelDataReader data = new(reader.FirstSheet); }); reader.Dispose(); Assert.False(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs b/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs index 24e4fe74..d4263468 100644 --- a/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs +++ b/tests/ExcelReader.Tests/Reader/ExcelOpenAndOleErrorTests.cs @@ -17,7 +17,7 @@ public void OpenDetectsXlsxFromStream() using var reader = Excel.Open(ms); XlsxWorkbook xlsx = Assert.IsType(reader); - using XlsxWorkbook.Enumerator e = xlsx.GetEnumerator(); + using XlsxWorkbook.Enumerator e = xlsx.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Hello", e.Current[0].GetString()); } @@ -29,7 +29,7 @@ public void OpenDetectsXlsFromStream() using var reader = Excel.Open(ms); XlsWorkbook xls = Assert.IsType(reader); - using XlsWorkbook.Enumerator e = xls.GetEnumerator(); + using XlsWorkbook.Enumerator e = xls.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); } @@ -121,7 +121,7 @@ public void OpenLeavesSeekableStreamAtOriginalPositionForReader() using MemoryStream ms = WorkbookBuilder.Build("""7"""); ms.Position = 0; using var reader = Excel.Open(ms, leaveOpen: true); - using XlsxWorkbook.Enumerator e = ((XlsxWorkbook)reader).GetEnumerator(); + using XlsxWorkbook.Enumerator e = ((XlsxWorkbook)reader).FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("7", e.Current[0].GetString()); } @@ -313,7 +313,7 @@ public void NonSeekableXlsSourceIsBufferedAndRead() using NonSeekableStream stream = new(bytes); using XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); - using XlsWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.True(e.MoveNext()); @@ -328,7 +328,7 @@ public async Task NonSeekableXlsSourceIsBufferedAndReadAsync() await using XlsWorkbook reader = await Excel.FromXlsAsync( stream, leaveOpen: false, ct: TestContext.Current.CancellationToken); - using XlsWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Async", e.Current[0].GetString()); } @@ -341,7 +341,7 @@ public void FromXlsxFileOpensLikeFromFile() try { using XlsxWorkbook reader = Excel.FromXlsxFile(path); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); } @@ -356,12 +356,12 @@ public void FromXlsxStreamAndMemoryOpenLikeFrom() { using MemoryStream ms = WorkbookBuilder.Build("""7"""); using XlsxWorkbook streamReader = Excel.FromXlsx(ms, leaveOpen: true); - using XlsxWorkbook.Enumerator se = streamReader.GetEnumerator(); + using XlsxWorkbook.Enumerator se = streamReader.FirstSheet.GetEnumerator(); Assert.True(se.MoveNext()); Assert.Equal("7", se.Current[0].GetString()); using XlsxWorkbook memoryReader = Excel.FromXlsx(ms.ToArray().AsMemory()); - using XlsxWorkbook.Enumerator me = memoryReader.GetEnumerator(); + using XlsxWorkbook.Enumerator me = memoryReader.FirstSheet.GetEnumerator(); Assert.True(me.MoveNext()); Assert.Equal("7", me.Current[0].GetString()); } @@ -374,7 +374,7 @@ public async Task FromXlsxFileAsyncAndFromXlsxAsyncOpenLikeTheirCanonicalCounter try { await using XlsxWorkbook fileReader = await Excel.FromXlsxFileAsync(path, ct: ct); - await using XlsxWorkbook.Enumerator fe = fileReader.GetEnumerator(); + await using XlsxWorkbook.Enumerator fe = fileReader.FirstSheet.GetEnumerator(); Assert.True(fe.MoveNext()); Assert.Equal("9", fe.Current[0].GetString()); } @@ -385,7 +385,7 @@ public async Task FromXlsxFileAsyncAndFromXlsxAsyncOpenLikeTheirCanonicalCounter await using MemoryStream ms = WorkbookBuilder.Build("""3"""); await using XlsxWorkbook streamReader = await Excel.FromXlsxAsync(ms, ct: ct); - await using XlsxWorkbook.Enumerator se = streamReader.GetEnumerator(); + await using XlsxWorkbook.Enumerator se = streamReader.FirstSheet.GetEnumerator(); Assert.True(se.MoveNext()); Assert.Equal("3", se.Current[0].GetString()); } diff --git a/tests/ExcelReader.Tests/Reader/FastDateTests.cs b/tests/ExcelReader.Tests/Reader/FastDateTests.cs index e9c6be87..b4833bd3 100644 --- a/tests/ExcelReader.Tests/Reader/FastDateTests.cs +++ b/tests/ExcelReader.Tests/Reader/FastDateTests.cs @@ -139,7 +139,7 @@ public void CsvTextDatesAreReadableThroughTryGetDateTime() using CsvReader reader = Excel.FromCsv(ms); var dates = new List(); - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { int col = 0; foreach (RowCell rowCell in row.Cells) @@ -163,7 +163,7 @@ public void NumericCellsStillReadAsExcelSerials() using var ms = new MemoryStream(csv, writable: false); using CsvReader reader = Excel.FromCsv(ms); - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { Assert.True(row[1].TryGetDateTime(out DateTime d)); Assert.Equal(new DateTime(2024, 3, 15), d); diff --git a/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs b/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs index 559df8a3..d5e63347 100644 --- a/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/MemorySourceParityTests.cs @@ -77,8 +77,8 @@ public void Should_Match_Stream_Path_When_Encrypted_Opened_From_Memory(string fi { var options = ExcelReaderOptions.Default with { Password = EncryptedFixtures.PasswordFor(fixture) }; - using IExcelRowReader streamReader = Excel.Open(EncryptedFixtures.Path_(fixture), options); - using IExcelRowReader memoryReader = Excel.Open(EncryptedFixtures.Bytes(fixture), options); + using IExcelWorkbook streamReader = Excel.Open(EncryptedFixtures.Path_(fixture), options); + using IExcelWorkbook memoryReader = Excel.Open(EncryptedFixtures.Bytes(fixture), options); AssertRowsEqual(streamReader, memoryReader); } @@ -93,18 +93,22 @@ public static TheoryData EncryptedFixtureNames() return data; } - private static void AssertRowsEqual(IExcelRowReader expected, IExcelRowReader actual) + private static void AssertRowsEqual(IExcelWorkbook expected, IExcelWorkbook actual) { - string[] expectedValues = ReadRows(expected); - string[] actualValues = ReadRows(actual); + Assert.Equal(expected.SheetCount, actual.SheetCount); + for (int n = 0; n < expected.SheetCount; n++) + { + string[] expectedValues = ReadRows(expected.SheetAt(n)); + string[] actualValues = ReadRows(actual.SheetAt(n)); - Assert.Equal(expectedValues, actualValues); + Assert.Equal(expectedValues, actualValues); + } } - private static string[] ReadRows(IExcelRowReader reader) + private static string[] ReadRows(IExcelSheet sheet) { List values = []; - foreach (Row row in reader) + foreach (Row row in sheet) { StringBuilder sb = new(); foreach (var cell in row.Cells) diff --git a/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs b/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs index c854c9ab..42f40b54 100644 --- a/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs +++ b/tests/ExcelReader.Tests/Reader/MidStreamCancellationTests.cs @@ -20,7 +20,7 @@ public async Task XlsxCancellationMidLargeSheetThrows() await using MemoryStream ms = new(bytes, writable: false); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: outer); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); for (int i = 0; i < 5; i++) { @@ -46,7 +46,7 @@ public async Task CsvCancellationMidLargeSheetThrows() await using MemoryStream ms = new(bytes, writable: false); await using CsvReader reader = await Excel.FromCsvAsync(ms, ct: outer); - await using CsvReader.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using CsvReader.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); for (int i = 0; i < 5; i++) { @@ -71,7 +71,7 @@ public async Task XlsCancellationAfterCancelThrowsOnNextMoveNext() using CancellationTokenSource cts = new(); await using XlsWorkbook reader = await Excel.FromXlsAsync(ms, ct: outer); - await using XlsWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); @@ -88,7 +88,7 @@ public async Task XlsbCancellationAfterCancelThrowsOnNextMoveNext() await using MemoryStream ms = new(bytes, writable: false); await using XlsbWorkbook reader = await Excel.FromXlsbAsync(ms, ct: outer); - await using XlsbWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); diff --git a/tests/ExcelReader.Tests/Reader/MultiSheetTests.cs b/tests/ExcelReader.Tests/Reader/MultiSheetTests.cs index 13bdfa16..e8f691b5 100644 --- a/tests/ExcelReader.Tests/Reader/MultiSheetTests.cs +++ b/tests/ExcelReader.Tests/Reader/MultiSheetTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using ExcelReader.Core; using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xlsx; namespace ExcelReader.Tests.Reader { @@ -16,54 +18,55 @@ public async Task SheetCountMatchesWorkbookSheetCount() } [Fact] - public async Task SheetNameMatchesCurrentSheet() + public async Task FirstSheetCarriesTheWorkbookSheetName() { await using var ms = await TypedWorkbook.BuildMultiSheetAsync(("MySheet", [])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Assert.Equal("MySheet", reader.SheetName); + Assert.Equal("MySheet", reader.FirstSheet.Name); } [Fact] - public async Task TryMoveToSheetMatchesCaseInsensitively() + public async Task TryGetSheetMatchesCaseInsensitively() { await using var ms = await TypedWorkbook.BuildMultiSheetAsync(("Sheet1", [])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Assert.True(reader.TryMoveToSheet("sheet1")); - Assert.Equal("Sheet1", reader.SheetName); + Assert.True(reader.TryGetSheet("sheet1", out XlsxSheet sheet)); + Assert.Equal("Sheet1", sheet.Name); } [Fact] - public async Task TryMoveToSheetReturnsFalseWhenNotFound() + public async Task TryGetSheetReturnsFalseWhenNotFound() { await using var ms = await TypedWorkbook.BuildAsync(); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Assert.False(reader.TryMoveToSheet("DoesNotExist")); + Assert.False(reader.TryGetSheet("DoesNotExist", out _)); } [Fact] - public async Task MoveToSheetByIndexSwitchesCurrentSheet() + public async Task SheetsIndexerReturnsTheSheetAtThatIndex() { await using var ms = await TypedWorkbook.BuildMultiSheetAsync( ("First", []), ("Second", [])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - reader.MoveToSheet(1); - Assert.Equal("Second", reader.SheetName); + Assert.Equal("Second", reader.Sheets[1].Name); } [Fact] - public async Task MoveToSheetNegativeIndexThrows() + public async Task NegativeSheetIndexThrows() { await using var ms = await TypedWorkbook.BuildAsync(); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Assert.Throws(() => reader.MoveToSheet(-1)); + Assert.Throws(() => reader.Sheets[-1]); + Assert.Throws(() => ((IExcelWorkbook)reader).SheetAt(-1)); } [Fact] - public async Task MoveToSheetOutOfRangeIndexThrows() + public async Task OutOfRangeSheetIndexThrows() { await using var ms = await TypedWorkbook.BuildAsync(); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Assert.Throws(() => reader.MoveToSheet(1)); + Assert.Throws(() => reader.Sheets[reader.SheetCount]); + Assert.Throws(() => ((IExcelWorkbook)reader).SheetAt(reader.SheetCount)); } [Fact] @@ -72,15 +75,15 @@ public async Task SheetNavigationWorksThroughFormatAgnosticInterface() await using var ms = await TypedWorkbook.BuildMultiSheetAsync( ("A", [[11]]), ("B", [[22]])); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); var names = new List(); var firstCells = new List(); for (int i = 0; i < reader.SheetCount; i++) { - reader.MoveToSheet(i); - names.Add(reader.SheetName); - await using var e = reader.GetEnumerator(); + IExcelSheet sheet = reader.SheetAt(i); + names.Add(sheet.Name); + await using var e = sheet.GetEnumerator(); Assert.True(await e.MoveNextAsync()); Assert.True(e.Current[0].TryParse(null, out int v)); firstCells.Add(v); @@ -92,18 +95,18 @@ public async Task SheetNavigationWorksThroughFormatAgnosticInterface() [Fact] [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", - Justification = "The test deliberately exercises CSV through the IExcelRowReader interface, the contract every format shares.")] + Justification = "The test deliberately exercises CSV through the IExcelWorkbook interface, the contract every format shares.")] public void CsvIsExposedAsSingleUnnamedSheet() { using var ms = new MemoryStream("h\nv\n"u8.ToArray()); - using IExcelRowReader reader = Excel.FromCsv(ms); + using IExcelWorkbook reader = Excel.FromCsv(ms); Assert.Equal(1, reader.SheetCount); - Assert.Equal("", reader.SheetName); - reader.MoveToSheet(0); - Assert.Throws(() => reader.MoveToSheet(1)); - Assert.True(reader.TryMoveToSheet("")); - Assert.False(reader.TryMoveToSheet("Sheet1")); + Assert.Equal("", reader.FirstSheet.Name); + Assert.Equal(ExcelSheetVisibility.Visible, reader.FirstSheet.Visibility); + Assert.Throws(() => reader.SheetAt(1)); + Assert.True(reader.TryGetSheet("", out _)); + Assert.False(reader.TryGetSheet("Sheet1", out _)); } [Fact] @@ -114,14 +117,12 @@ public async Task MultipleSheetsEachHaveDistinctData() ("B", [[22]])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var e1 = reader.GetEnumerator(); + await using var e1 = reader.Sheets[0].GetEnumerator(); Assert.True(await e1.MoveNextAsync()); Assert.True(e1.Current[0].TryParse(null, out int v1)); Assert.Equal(11, v1); - reader.MoveToSheet(1); - - await using var e2 = reader.GetEnumerator(); + await using var e2 = reader.Sheets[1].GetEnumerator(); Assert.True(await e2.MoveNextAsync()); Assert.True(e2.Current[0].TryParse(null, out int v2)); Assert.Equal(22, v2); diff --git a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs index 834def0f..459e19eb 100644 --- a/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs +++ b/tests/ExcelReader.Tests/Reader/ReaderLimitTests.cs @@ -55,7 +55,7 @@ public void FourOrMoreColumnLettersThrowColumnLimit(string cellRef) using XlsxWorkbook reader = Excel.FromXlsx(built); Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); }); } @@ -66,7 +66,7 @@ public void LastColumnXfdStillReads() using MemoryStream built = WorkbookBuilder.Build("""1"""); using XlsxWorkbook reader = Excel.FromXlsx(built); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(16_384, e.Current.ColumnCount); } @@ -134,7 +134,7 @@ public void ForgedOversizedSharedStringsEntryTripsSharedStringLimitBeforeReading using XlsxWorkbook reader = Excel.FromXlsx(forged, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); Assert.Equal(50_000_000, ex.Actual); @@ -161,7 +161,7 @@ public void ImplausibleUniqueCountTripsSharedStringLimitBeforeAllocating() using XlsxWorkbook reader = Excel.FromXlsx(ms); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); Assert.Equal(500_000_000, ex.Actual); @@ -305,7 +305,7 @@ public void CompressedSheetTripsTotalDecompressedLimit() using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -329,7 +329,7 @@ public void SharedStringsTripSharedStringLimit() using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); }); Assert.Equal(nameof(ExcelReaderOptions.MaxSharedStringBytes), ex.LimitName); } @@ -347,7 +347,7 @@ public void ZeroTotalDecompressedLimitRestoresUnlimitedTotalRead() }; using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(value.Length, e.Current[0].GetString().Length); } @@ -503,7 +503,7 @@ public void Should_Ignore_SpinCount_Cap_When_Scheme_Is_Standard() MaxPasswordSpinCount = 1, }; - using IExcelRowReader reader = Excel.Open(container, options); + using IExcelWorkbook reader = Excel.Open(container, options); Assert.True(reader.SheetCount > 0); } @@ -548,8 +548,8 @@ public void Should_Reject_Gracefully_When_Encrypted_Container_Mutated(string fix { try { - using IExcelRowReader reader = Excel.Open(mutations[i], options); - foreach (Row row in reader) + using IExcelWorkbook reader = Excel.Open(mutations[i], options); + foreach (Row row in reader.FirstSheet) { foreach (RowCell cell in row.Cells) { diff --git a/tests/ExcelReader.Tests/Reader/SampleTest.cs b/tests/ExcelReader.Tests/Reader/SampleTest.cs index 28378921..86e0bddc 100644 --- a/tests/ExcelReader.Tests/Reader/SampleTest.cs +++ b/tests/ExcelReader.Tests/Reader/SampleTest.cs @@ -11,7 +11,7 @@ public void ReadsSharedStringsStylesAndNumbers() using var reader = Excel.FromXlsxFile(path); int r = 0; - foreach (var row in reader) + foreach (var row in reader.FirstSheet) { if (r == 0) { @@ -42,7 +42,7 @@ public async Task HandlesSparseCellsAndBufferGrowth() $$"""1030{{big}}"""); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using var enumerator = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using var enumerator = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); int r = 0; while (await enumerator.MoveNextAsync()) { @@ -71,7 +71,7 @@ public void CellsEnumeratesOnlyPopulatedCellsInColumnOrder() """1030"""); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; @@ -98,7 +98,7 @@ public void DecodesXmlEntitiesInSharedStrings() sharedStrings: "a & b <tag> A"); using var reader = Excel.FromXlsx(ms); - using var enumerator = reader.GetEnumerator(); + using var enumerator = reader.FirstSheet.GetEnumerator(); Assert.True(enumerator.MoveNext(), "Expected at least one row"); var row = enumerator.Current; Assert.Equal("a & b A", row[0].GetString()); @@ -112,7 +112,7 @@ public void DecodePassesThroughLoneAmpersandAndUnknownEntities() sharedStrings: "a&bx&foo;y"); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.Equal("a&b", row[0].GetString()); @@ -130,7 +130,7 @@ public void DetectsDateStylesAndConvertsSerial() styles: styles); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; @@ -151,7 +151,7 @@ public async Task AsyncReadsSampleFileLikeSyncPath() var ct = TestContext.Current.CancellationToken; string path = Path.Combine(AppContext.BaseDirectory, "data", "sample.xlsx"); await using var reader = await Excel.FromXlsxFileAsync(path, ct: ct); - await using var e = reader.GetAsyncEnumerator(ct); + await using var e = reader.FirstSheet.GetAsyncEnumerator(ct); int r = 0; while (await e.MoveNextAsync()) @@ -184,7 +184,7 @@ public async Task AsyncHandlesBufferGrowthAndDates() styles: styles); await using var reader = await Excel.FromXlsxAsync(ms, ct: ct); - await using var e = reader.GetAsyncEnumerator(ct); + await using var e = reader.FirstSheet.GetAsyncEnumerator(ct); Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Date, e.Current[0].Type); diff --git a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs index 195b9317..984a013c 100644 --- a/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs +++ b/tests/ExcelReader.Tests/Reader/SchemaInferenceTests.cs @@ -33,7 +33,7 @@ public void Should_CompareByValue_When_TwoSchemasDescribeTheSameColumn() private static ExcelColumnSchema[] InferFromCsv(string csv, int headerRow = 1, int sampleSize = 100, bool parseText = false) { using CsvReader reader = Excel.FromCsv(System.Text.Encoding.UTF8.GetBytes(csv)); - using var rows = reader.GetEnumerator(); + using var rows = reader.FirstSheet.GetEnumerator(); return SchemaInference.Infer(rows, isDate1904: false, headerRow, sampleSize, parseText); } @@ -41,7 +41,7 @@ private static ExcelColumnSchema[] InferFromXlsx(string sheetRows, int headerRow { using MemoryStream ms = WorkbookBuilder.Build(sheetRows); using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - using var rows = reader.GetEnumerator(); + using var rows = reader.FirstSheet.GetEnumerator(); return SchemaInference.Infer(rows, isDate1904: false, headerRow, sampleSize, parseText); } @@ -67,7 +67,7 @@ public void InferSchema_Should_Type_The_Fixture_Csv_When_Parsing_Text() { using CsvReader reader = Excel.FromCsvFile(FixtureCsv()); - ExcelColumnSchema[] schema = Excel.InferSchema(reader, parseText: true); + ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet, parseText: true); ExcelColumnType s = ExcelColumnType.StringColumn, d = ExcelColumnType.DateColumn, i = ExcelColumnType.Int64Column, f = ExcelColumnType.Float64Column; @@ -80,7 +80,7 @@ public void InferSchema_Should_Keep_The_Fixture_Csv_As_Strings_By_Default() { using CsvReader reader = Excel.FromCsvFile(FixtureCsv()); - Assert.All(Excel.InferSchema(reader), column => Assert.Equal(ExcelColumnType.StringColumn, column.Type)); + Assert.All(Excel.InferSchema(reader.FirstSheet), column => Assert.Equal(ExcelColumnType.StringColumn, column.Type)); } [Fact] @@ -291,8 +291,8 @@ public void Should_SampleAtMostSampleSizeRows_When_TheSheetIsLonger() [Fact] public void Should_InferAcrossEveryFormat_When_CalledThroughTheExcelFacade() { - using IExcelRowReader reader = Excel.Open(Path.Combine("data", "RealExcel.xlsb")); - ExcelColumnSchema[] schema = Excel.InferSchema(reader); + using IExcelWorkbook reader = Excel.Open(Path.Combine("data", "RealExcel.xlsb")); + ExcelColumnSchema[] schema = Excel.InferSchema(reader.FirstSheet); Assert.NotEmpty(schema); Assert.All(schema, column => Assert.True(column.Index >= 0)); @@ -305,10 +305,10 @@ public void Should_InferAcrossEveryFormat_When_CalledThroughTheExcelFacade() [Fact] public void Should_NotDisturbTheReader_When_InferSchemaRunsBeforeEnumeration() { - using IExcelRowReader reader = Excel.FromCsv(System.Text.Encoding.UTF8.GetBytes("Id\n1\n2\n")); - _ = Excel.InferSchema(reader); + using IExcelWorkbook reader = Excel.FromCsv(System.Text.Encoding.UTF8.GetBytes("Id\n1\n2\n")); + _ = Excel.InferSchema(reader.FirstSheet); - using IExcelRowEnumerator rows = reader.GetEnumerator(); + using IExcelRowEnumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal("Id", rows.Current[0].GetString()); } @@ -316,7 +316,7 @@ public void Should_NotDisturbTheReader_When_InferSchemaRunsBeforeEnumeration() [Fact] public void Should_Throw_When_ReaderIsNull() { - Assert.Throws(() => Excel.InferSchema((IExcelRowReader)null!)); + Assert.Throws(() => Excel.InferSchema((IExcelSheet)null!)); } } } diff --git a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs index ecddfbf9..d81988aa 100644 --- a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs @@ -60,22 +60,21 @@ public void Should_ReportEverySheetsState_When_ReadingXlsx() using var stream = new MemoryStream(xlsx, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(stream); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); - Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetVisibilityAt(1)); - Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.SheetVisibilityAt(2)); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[0].Visibility); + Assert.Equal(ExcelSheetVisibility.Hidden, reader.Sheets[1].Visibility); + Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.Sheets[2].Visibility); } [Fact] - public void Should_FollowTheCurrentSheet_When_MovingBetweenSheets() + public void Should_ReportEachSheetsState_When_ReadingThroughTheWorkbookInterface() { byte[] xlsx = BuildXlsx("visible", "hidden"); using var stream = new MemoryStream(xlsx, writable: false); - using IExcelRowReader reader = Excel.Open(stream); + using IExcelWorkbook reader = Excel.Open(stream); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibility); - reader.MoveToSheet(1); - Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetVisibility); + Assert.Equal(ExcelSheetVisibility.Visible, reader.FirstSheet.Visibility); + Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetAt(1).Visibility); } [Fact] @@ -85,8 +84,8 @@ public void Should_ReportEverySheetsState_When_ReadingXlsxFromMemory() using XlsxWorkbook reader = Excel.FromXlsx(xlsx.AsMemory()); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); - Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.SheetVisibilityAt(1)); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[0].Visibility); + Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.Sheets[1].Visibility); } [Fact] @@ -96,9 +95,9 @@ public void Should_TreatUnknownStatesAsVisible_When_ReadingXlsx() using XlsxWorkbook reader = Excel.FromXlsx(xlsx.AsMemory()); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(1)); - Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.SheetVisibilityAt(2)); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[0].Visibility); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[1].Visibility); + Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.Sheets[2].Visibility); } [Fact] @@ -106,8 +105,8 @@ public void Should_CarryVisibility_When_WalkingSheets() { byte[] xlsx = BuildXlsx(null, "hidden", "veryHidden"); - using IExcelRowReader reader = Excel.Open(xlsx.AsMemory()); - ExcelSheet[] sheets = [.. reader.Sheets()]; + using IExcelWorkbook reader = Excel.Open(xlsx.AsMemory()); + IExcelSheet[] sheets = [.. Enumerable.Range(0, reader.SheetCount).Select(reader.SheetAt)]; Assert.Equal( [ExcelSheetVisibility.Visible, ExcelSheetVisibility.Hidden, ExcelSheetVisibility.VeryHidden], @@ -145,9 +144,9 @@ public void Should_DecodeHsState_When_ReadingXlsBoundSheet() using XlsWorkbook reader = Excel.FromXls(xls); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); - Assert.Equal(ExcelSheetVisibility.Hidden, reader.SheetVisibilityAt(1)); - Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.SheetVisibilityAt(2)); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[0].Visibility); + Assert.Equal(ExcelSheetVisibility.Hidden, reader.Sheets[1].Visibility); + Assert.Equal(ExcelSheetVisibility.VeryHidden, reader.Sheets[2].Visibility); } [Fact] @@ -155,9 +154,9 @@ public void Should_ReportVisible_When_ReadingCsv() { using CsvReader reader = Excel.FromCsv("a,b\n"u8.ToArray().AsMemory()); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibility); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibilityAt(0)); - Assert.Throws(() => reader.SheetVisibilityAt(1)); + Assert.Equal(ExcelSheetVisibility.Visible, reader.FirstSheet.Visibility); + Assert.Equal(ExcelSheetVisibility.Visible, reader.Sheets[0].Visibility); + Assert.Throws(() => reader.Sheets[1].Visibility); } private static readonly (string Name, ExcelSheetVisibility Visibility)[] _mixedSheets = @@ -188,13 +187,13 @@ private static void WriteSheets( private static void AssertRoundTrips(MemoryStream written) { written.Position = 0; - using IExcelRowReader reader = Excel.Open(written); + using IExcelWorkbook reader = Excel.Open(written); Assert.Equal(_mixedSheets.Length, reader.SheetCount); for (int i = 0; i < _mixedSheets.Length; i++) { - Assert.Equal(_mixedSheets[i].Name, reader.SheetNameAt(i)); - Assert.Equal(_mixedSheets[i].Visibility, reader.SheetVisibilityAt(i)); + Assert.Equal(_mixedSheets[i].Name, reader.SheetAt(i).Name); + Assert.Equal(_mixedSheets[i].Visibility, reader.SheetAt(i).Visibility); } } @@ -244,8 +243,8 @@ public void Should_DefaultToVisible_When_AddingASheetWithoutAVisibility() } ms.Position = 0; - using IExcelRowReader reader = Excel.Open(ms); - Assert.Equal(ExcelSheetVisibility.Visible, reader.SheetVisibility); + using IExcelWorkbook reader = Excel.Open(ms); + Assert.Equal(ExcelSheetVisibility.Visible, reader.FirstSheet.Visibility); Assert.DoesNotContain("state=", Encoding.UTF8.GetString(WorkbookPart(ms)), StringComparison.Ordinal); } @@ -339,8 +338,8 @@ public void Should_Reject_When_TheSheetIndexIsOutOfRange() { using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx([null]).AsMemory()); - Assert.Throws(() => reader.SheetVisibilityAt(-1)); - Assert.Throws(() => reader.SheetVisibilityAt(1)); + Assert.Throws(() => reader.Sheets[-1].Visibility); + Assert.Throws(() => reader.Sheets[1].Visibility); } } } diff --git a/tests/ExcelReader.Tests/Reader/SourceParityTests.cs b/tests/ExcelReader.Tests/Reader/SourceParityTests.cs index a33ab963..51b663e5 100644 --- a/tests/ExcelReader.Tests/Reader/SourceParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SourceParityTests.cs @@ -93,13 +93,12 @@ private XlsxWorkbook OpenXlsx(Kind kind, byte[] bytes) } } - private static List Dump(IExcelRowReader reader) + private static List Dump(IExcelWorkbook reader) { List lines = []; for (int s = 0; s < reader.SheetCount; s++) { - reader.MoveToSheet(s); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(s).GetEnumerator(); while (e.MoveNext()) { lines.Add(Line(s, e.Current)); @@ -108,13 +107,12 @@ private static List Dump(IExcelRowReader reader) return lines; } - private static async Task> DumpAsync(IExcelRowReader reader) + private static async Task> DumpAsync(IExcelWorkbook reader) { List lines = []; for (int s = 0; s < reader.SheetCount; s++) { - reader.MoveToSheet(s); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using IExcelRowEnumerator e = reader.SheetAt(s).GetAsyncEnumerator(TestContext.Current.CancellationToken); while (await e.MoveNextAsync()) { lines.Add(Line(s, e.Current)); @@ -138,8 +136,8 @@ private static string Line(int sheet, Row row) public void Xlsx_Reads_The_Same_Rows_From_Every_Source(Kind kind) { byte[] bytes = BuildXlsx(); - using IExcelRowReader expected = Excel.FromXlsx(bytes); - using IExcelRowReader actual = OpenXlsx(kind, bytes); + using IExcelWorkbook expected = Excel.FromXlsx(bytes); + using IExcelWorkbook actual = OpenXlsx(kind, bytes); Assert.Equal(Dump(expected), Dump(actual)); } @@ -148,8 +146,8 @@ public void Xlsx_Reads_The_Same_Rows_From_Every_Source(Kind kind) public async Task Xlsx_Reads_The_Same_Rows_Asynchronously_From_Every_Source(Kind kind) { byte[] bytes = BuildXlsx(); - using IExcelRowReader expected = Excel.FromXlsx(bytes); - using IExcelRowReader actual = OpenXlsx(kind, bytes); + using IExcelWorkbook expected = Excel.FromXlsx(bytes); + using IExcelWorkbook actual = OpenXlsx(kind, bytes); Assert.Equal(Dump(expected), await DumpAsync(actual)); } @@ -158,11 +156,9 @@ public async Task Xlsx_Reads_The_Same_Rows_Asynchronously_From_Every_Source(Kind public void Xlsx_Interleaved_Enumerators_Keep_Their_Own_Position(Kind kind) { byte[] bytes = BuildXlsx(); - using IExcelRowReader reader = OpenXlsx(kind, bytes); - reader.MoveToSheet(0); - using IExcelRowEnumerator first = reader.GetEnumerator(); - reader.MoveToSheet(1); - using IExcelRowEnumerator second = reader.GetEnumerator(); + using IExcelWorkbook reader = OpenXlsx(kind, bytes); + using IExcelRowEnumerator first = reader.SheetAt(0).GetEnumerator(); + using IExcelRowEnumerator second = reader.SheetAt(1).GetEnumerator(); for (int r = 0; r < 400; r++) { Assert.True(first.MoveNext()); @@ -178,7 +174,7 @@ public void Xlsx_Interleaved_Enumerators_Keep_Their_Own_Position(Kind kind) public void Xlsx_File_Can_Be_Deleted_After_Dispose() { string path = WriteTemp(BuildXlsx(), ".xlsx"); - using (IExcelRowReader reader = Excel.FromXlsxFile(path)) + using (IExcelWorkbook reader = Excel.FromXlsxFile(path)) { Assert.NotEmpty(Dump(reader)); } @@ -191,7 +187,7 @@ public void Xlsx_Leaves_A_Borrowed_Stream_Usable() { byte[] bytes = BuildXlsx(); using TrickleStream stream = new(bytes); - using (IExcelRowReader reader = Excel.FromXlsx(stream, leaveOpen: true)) + using (IExcelWorkbook reader = Excel.FromXlsx(stream, leaveOpen: true)) { Assert.NotEmpty(Dump(reader)); } @@ -258,8 +254,8 @@ private XlsWorkbook OpenXls(Kind kind, byte[] bytes) public void Xls_Reads_The_Same_Rows_From_Every_Source(Kind kind) { byte[] bytes = BuildXls(); - using IExcelRowReader expected = Excel.FromXls(bytes); - using IExcelRowReader actual = OpenXls(kind, bytes); + using IExcelWorkbook expected = Excel.FromXls(bytes); + using IExcelWorkbook actual = OpenXls(kind, bytes); Assert.Equal(Dump(expected), Dump(actual)); } @@ -268,11 +264,9 @@ public void Xls_Reads_The_Same_Rows_From_Every_Source(Kind kind) public void Xls_Interleaved_Enumerators_Keep_Their_Own_Position(Kind kind) { byte[] bytes = BuildXls(); - using IExcelRowReader reader = OpenXls(kind, bytes); - reader.MoveToSheet(0); - using IExcelRowEnumerator first = reader.GetEnumerator(); - reader.MoveToSheet(1); - using IExcelRowEnumerator second = reader.GetEnumerator(); + using IExcelWorkbook reader = OpenXls(kind, bytes); + using IExcelRowEnumerator first = reader.SheetAt(0).GetEnumerator(); + using IExcelRowEnumerator second = reader.SheetAt(1).GetEnumerator(); for (int r = 0; r < 400; r++) { Assert.True(first.MoveNext()); @@ -288,7 +282,7 @@ public void Xls_Interleaved_Enumerators_Keep_Their_Own_Position(Kind kind) public void Xls_File_Can_Be_Deleted_After_Dispose() { string path = WriteTemp(BuildXls(), ".xls"); - using (IExcelRowReader reader = Excel.FromXlsFile(path)) + using (IExcelWorkbook reader = Excel.FromXlsFile(path)) { Assert.NotEmpty(Dump(reader)); } @@ -348,8 +342,8 @@ private XlsbWorkbook OpenXlsb(Kind kind, byte[] bytes) public void Xlsb_Reads_The_Same_Rows_From_Every_Source(Kind kind) { byte[] bytes = BuildXlsb(); - using IExcelRowReader expected = Excel.FromXlsb(bytes); - using IExcelRowReader actual = OpenXlsb(kind, bytes); + using IExcelWorkbook expected = Excel.FromXlsb(bytes); + using IExcelWorkbook actual = OpenXlsb(kind, bytes); Assert.Equal(Dump(expected), Dump(actual)); } @@ -358,8 +352,8 @@ public void Xlsb_Reads_The_Same_Rows_From_Every_Source(Kind kind) public async Task Xlsb_Reads_The_Same_Rows_Asynchronously_From_Every_Source(Kind kind) { byte[] bytes = BuildXlsb(); - using IExcelRowReader expected = Excel.FromXlsb(bytes); - using IExcelRowReader actual = OpenXlsb(kind, bytes); + using IExcelWorkbook expected = Excel.FromXlsb(bytes); + using IExcelWorkbook actual = OpenXlsb(kind, bytes); Assert.Equal(Dump(expected), await DumpAsync(actual)); } @@ -367,7 +361,7 @@ public async Task Xlsb_Reads_The_Same_Rows_Asynchronously_From_Every_Source(Kind public void Xlsb_File_Can_Be_Deleted_After_Dispose() { string path = WriteTemp(BuildXlsb(), ".xlsb"); - using (IExcelRowReader reader = Excel.FromXlsbFile(path)) + using (IExcelWorkbook reader = Excel.FromXlsbFile(path)) { Assert.NotEmpty(Dump(reader)); } @@ -436,13 +430,13 @@ public async Task DetectFileFormatAsync_Leaves_The_Stream_Open_At_Its_Position(K public void Open_Detects_And_Reads_Both_Zip_Formats(Kind kind) { byte[] xlsx = BuildXlsx(); - using IExcelRowReader expectedXlsx = Excel.FromXlsx(xlsx); - using IExcelRowReader actualXlsx = Excel.Open(OpenRawStream(kind, xlsx), leaveOpen: false); + using IExcelWorkbook expectedXlsx = Excel.FromXlsx(xlsx); + using IExcelWorkbook actualXlsx = Excel.Open(OpenRawStream(kind, xlsx), leaveOpen: false); Assert.Equal(Dump(expectedXlsx), Dump(actualXlsx)); byte[] xlsb = BuildXlsb(); - using IExcelRowReader expectedXlsb = Excel.FromXlsb(xlsb); - using IExcelRowReader actualXlsb = Excel.Open(OpenRawStream(kind, xlsb), leaveOpen: false); + using IExcelWorkbook expectedXlsb = Excel.FromXlsb(xlsb); + using IExcelWorkbook actualXlsb = Excel.Open(OpenRawStream(kind, xlsb), leaveOpen: false); Assert.Equal(Dump(expectedXlsb), Dump(actualXlsb)); } @@ -452,13 +446,13 @@ public async Task OpenAsync_Detects_And_Reads_Both_Zip_Formats(Kind kind) { CancellationToken ct = TestContext.Current.CancellationToken; byte[] xlsx = BuildXlsx(); - using IExcelRowReader expectedXlsx = Excel.FromXlsx(xlsx); - using IExcelRowReader actualXlsx = await Excel.OpenAsync(OpenRawStream(kind, xlsx), leaveOpen: false, ct: ct); + using IExcelWorkbook expectedXlsx = Excel.FromXlsx(xlsx); + using IExcelWorkbook actualXlsx = await Excel.OpenAsync(OpenRawStream(kind, xlsx), leaveOpen: false, ct: ct); Assert.Equal(Dump(expectedXlsx), await DumpAsync(actualXlsx)); byte[] xlsb = BuildXlsb(); - using IExcelRowReader expectedXlsb = Excel.FromXlsb(xlsb); - using IExcelRowReader actualXlsb = await Excel.OpenAsync(OpenRawStream(kind, xlsb), leaveOpen: false, ct: ct); + using IExcelWorkbook expectedXlsb = Excel.FromXlsb(xlsb); + using IExcelWorkbook actualXlsb = await Excel.OpenAsync(OpenRawStream(kind, xlsb), leaveOpen: false, ct: ct); Assert.Equal(Dump(expectedXlsb), await DumpAsync(actualXlsb)); } diff --git a/tests/ExcelReader.Tests/Reader/SyncAsyncParityTests.cs b/tests/ExcelReader.Tests/Reader/SyncAsyncParityTests.cs index 0749e338..79ce86a5 100644 --- a/tests/ExcelReader.Tests/Reader/SyncAsyncParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SyncAsyncParityTests.cs @@ -35,11 +35,11 @@ public async Task SyncAndAsyncEnumeratorsProduceIdenticalCells(ParityFixture fix Assert.Equal(sync, asyncEnum); } - private static List ReadSync(byte[] workbook, Func open) + private static List ReadSync(byte[] workbook, Func open) { using MemoryStream stream = new(workbook, writable: false); - using IExcelRowReader reader = open(stream); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = open(stream); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); List cells = []; int rowIndex = 0; while (e.MoveNext()) @@ -51,12 +51,12 @@ private static List ReadSync(byte[] workbook, Func> ReadAsync( byte[] workbook, - Func> open, + Func> open, CancellationToken ct) { await using MemoryStream stream = new(workbook, writable: false); - await using IExcelRowReader reader = await open(stream, ct); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(ct); + await using IExcelWorkbook reader = await open(stream, ct); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); List cells = []; int rowIndex = 0; while (await e.MoveNextAsync()) @@ -66,11 +66,11 @@ private static async Task> ReadAsync( return cells; } - private static async Task> ReadViaAsyncEnumeratorAsync(byte[] workbook, Func open) + private static async Task> ReadViaAsyncEnumeratorAsync(byte[] workbook, Func open) { await using MemoryStream stream = new(workbook, writable: false); - await using IExcelRowReader reader = open(stream); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(); + await using IExcelWorkbook reader = open(stream); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(); List cells = []; int rowIndex = 0; while (await e.MoveNextAsync()) @@ -89,7 +89,7 @@ public async Task Should_Match_Across_Sync_And_Async_When_Encrypted(string fixtu byte[] workbook = EncryptedFixtures.Bytes(fixture); List sync = ReadSync(workbook, stream => Excel.Open(stream, options: options)); - List asyncCells = await ReadAsync(workbook, (stream, token) => Excel.OpenAsync(stream, options: options, ct: token), ct); + List asyncCells = await ReadAsync(workbook, async (stream, token) => await Excel.OpenAsync(stream, options: options, ct: token), ct); List asyncEnum = await ReadViaAsyncEnumeratorAsync(workbook, stream => Excel.Open(stream, options: options)); Assert.Equal(sync, asyncCells); @@ -241,22 +241,22 @@ private static ValueTask BuildCsvAsync(CancellationToken ct) return ValueTask.FromResult(System.Text.Encoding.UTF8.GetBytes(csv)); } - private static async ValueTask OpenCsvAsync(Stream stream, CancellationToken ct) + private static async ValueTask OpenCsvAsync(Stream stream, CancellationToken ct) { return await Excel.FromCsvAsync(stream, ct: ct); } - private static async ValueTask OpenXlsxAsync(Stream stream, CancellationToken ct) + private static async ValueTask OpenXlsxAsync(Stream stream, CancellationToken ct) { return await Excel.FromXlsxAsync(stream, ct: ct); } - private static async ValueTask OpenXlsAsync(Stream stream, CancellationToken ct) + private static async ValueTask OpenXlsAsync(Stream stream, CancellationToken ct) { return await Excel.FromXlsAsync(stream, ct: ct); } - private static async ValueTask OpenXlsbAsync(Stream stream, CancellationToken ct) + private static async ValueTask OpenXlsbAsync(Stream stream, CancellationToken ct) { return await Excel.FromXlsbAsync(stream, ct: ct); } @@ -264,8 +264,8 @@ private static async ValueTask OpenXlsbAsync(Stream stream, Can public sealed record ParityFixture( string Name, Func> Build, - Func OpenSync, - Func> OpenAsync) + Func OpenSync, + Func> OpenAsync) { public override string ToString() { diff --git a/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs b/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs index 35b596c2..cf354554 100644 --- a/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs +++ b/tests/ExcelReader.Tests/Reader/Utf8StringCacheTests.cs @@ -81,7 +81,7 @@ public void CsvInternStringsDefaultsToFalse() { using MemoryStream ms = new(Encoding.UTF8.GetBytes("alpha,1\nalpha,2\n")); using CsvReader reader = Excel.FromCsv(ms); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); string row1 = e.Current[0].GetString(); @@ -98,7 +98,7 @@ public void CsvInternStringsTrueEnablesDedupAcrossRows() var options = new CsvReaderOptions { InternStrings = true }; using MemoryStream ms = new(Encoding.UTF8.GetBytes("alpha,1\nalpha,2\nbeta,3\n")); using CsvReader reader = Excel.FromCsv(ms, options: options); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); string row1 = e.Current[0].GetString(); @@ -118,7 +118,7 @@ public async Task XlsxInternStringsDefaultsToFalse() { await using MemoryStream ms = await TypedWorkbook.BuildAsync(["alpha"], ["alpha"], ["beta"]); await using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); string row1 = e.Current[0].GetString(); @@ -135,7 +135,7 @@ public async Task XlsxInternStringsTrueEnablesDedupForInlineStrings() await using MemoryStream ms = await TypedWorkbook.BuildAsync(["alpha"], ["alpha"], ["beta"]); var options = new ExcelReaderOptions { InternStrings = true }; await using var reader = Excel.FromXlsx(ms, options: options); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); string row1 = e.Current[0].GetString(); From 2d4f998b2949ca976fd9243706dc05ed0cff47ce Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:51:54 -0300 Subject: [PATCH 08/49] test(core): read through sheets in the per-format reader tests Csv, Xls, Xlsb, Xlsx and Internal reader tests read through Sheets, FirstSheet, SheetAt and TryGetSheet instead of the reader's current sheet. The dispose tests assert on the workbook's Sheets and on a sheet taken before the dispose. The CSV dialect helper drops its v5 overloads. Co-Authored-By: Claude Sonnet 5.5 --- .../Reader/Csv/CsvBlockBoundaryTests.cs | 4 +- .../Reader/Csv/CsvDialectAndFormatTests.cs | 36 +++----- .../Reader/Csv/CsvLifetimeTests.cs | 28 +++--- .../Reader/Csv/CsvReaderTests.cs | 26 +++--- .../Reader/Csv/CsvSnifferTests.cs | 2 +- .../Reader/Internal/SharedStringCacheTests.cs | 2 +- .../Reader/Xls/XlsLifetimeTests.cs | 12 +-- .../Reader/Xls/XlsWorkbookTests.cs | 87 +++++++++---------- .../Reader/Xlsb/XlsbLifetimeTests.cs | 12 +-- .../Reader/Xlsb/XlsbParserTests.cs | 4 +- .../Reader/Xlsb/XlsbStructureTests.cs | 2 +- .../Reader/Xlsx/MemoryZipParityTests.cs | 84 +++++++++--------- .../Xlsx/NamespacePrefixAndIsoDateTests.cs | 28 +++--- .../Reader/Xlsx/PrefetchDecompressionTests.cs | 42 ++++----- .../Reader/Xlsx/RealWorldXlsxCorpusTests.cs | 2 +- .../Reader/Xlsx/XlsxDialectShapeTests.cs | 4 +- .../Reader/Xlsx/XlsxLifetimeTests.cs | 28 +++--- .../Xlsx/XlsxProducerDialectShapeTests.cs | 4 +- .../Reader/Xlsx/XlsxWorkbookTests.cs | 35 ++++---- .../Reader/Xlsx/XlsxXmlDialectTests.cs | 16 ++-- 20 files changed, 225 insertions(+), 233 deletions(-) diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs index 55d9b9ee..28d22f04 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvBlockBoundaryTests.cs @@ -19,7 +19,7 @@ public static TheoryData Offsets() private static List ReadAll(CsvReader reader) { var rows = new List(); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); while (e.MoveNext()) { var row = e.Current; @@ -82,7 +82,7 @@ public void EscapedQuotesSpanningSeveralBlocksAreUnescaped() public void EscapedFieldIsReportedAsMaterializedString() { using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("\"a\"\"b\",\"\"")); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.ExcelString, e.Current[0].Type); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs index 02fd0b87..8f38b216 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvDialectAndFormatTests.cs @@ -27,20 +27,10 @@ private static string WriteTemp(string content, Encoding? encoding = null) return WriteTemp((encoding ?? new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)).GetBytes(content)); } - private static List ReadAll(IExcelRowReader reader) - { - return ReadAll(reader.GetEnumerator()); - } - private static List ReadAll(IExcelSheet sheet) - { - return ReadAll(sheet.GetEnumerator()); - } - - private static List ReadAll(IExcelRowEnumerator enumerator) { var rows = new List(); - using IExcelRowEnumerator owned = enumerator; + using IExcelRowEnumerator owned = sheet.GetEnumerator(); while (owned.MoveNext()) { var cells = new List(); @@ -59,8 +49,8 @@ public void Should_ReadDelimitedText_When_FormatIsCsv() string path = WriteTemp("Name,Age\nAda,36\n"); try { - using IExcelRowReader reader = Excel.Open(path, ExcelFileFormat.Csv); - List rows = ReadAll(reader); + using IExcelWorkbook reader = Excel.Open(path, ExcelFileFormat.Csv); + List rows = ReadAll(reader.FirstSheet); Assert.Equal(2, rows.Count); Assert.Equal(["Ada", "36"], rows[1]); @@ -78,9 +68,9 @@ public void Should_HonorCsvOptions_When_FormatIsCsv() try { var options = new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { Delimiter = (byte)';' } }; - using IExcelRowReader reader = Excel.Open(path, ExcelFileFormat.Csv, options); + using IExcelWorkbook reader = Excel.Open(path, ExcelFileFormat.Csv, options); - Assert.Equal(["Ada", "London", "36"], ReadAll(reader)[1]); + Assert.Equal(["Ada", "London", "36"], ReadAll(reader.FirstSheet)[1]); } finally { @@ -95,12 +85,12 @@ public void Should_ReadDelimitedText_When_OpeningAStreamOrBufferAsCsv() using (var stream = new MemoryStream(csv, writable: false)) { - using IExcelRowReader fromStream = Excel.Open(stream, ExcelFileFormat.Csv); - Assert.Equal(["Ada", "36"], ReadAll(fromStream)[1]); + using IExcelWorkbook fromStream = Excel.Open(stream, ExcelFileFormat.Csv); + Assert.Equal(["Ada", "36"], ReadAll(fromStream.FirstSheet)[1]); } - using IExcelRowReader fromMemory = Excel.Open(csv.AsMemory(), ExcelFileFormat.Csv); - Assert.Equal(["Ada", "36"], ReadAll(fromMemory)[1]); + using IExcelWorkbook fromMemory = Excel.Open(csv.AsMemory(), ExcelFileFormat.Csv); + Assert.Equal(["Ada", "36"], ReadAll(fromMemory.FirstSheet)[1]); } [Fact] @@ -109,10 +99,10 @@ public async Task Should_ReadDelimitedText_When_OpeningAsyncAsCsv() string path = WriteTemp("Name,Age\nAda,36\n"); try { - IExcelRowReader reader = await Excel.OpenAsync(path, ExcelFileFormat.Csv, ct: TestContext.Current.CancellationToken); + IExcelWorkbook reader = await Excel.OpenAsync(path, ExcelFileFormat.Csv, ct: TestContext.Current.CancellationToken); using (reader) { - Assert.Equal(["Ada", "36"], ReadAll(reader)[1]); + Assert.Equal(["Ada", "36"], ReadAll(reader.FirstSheet)[1]); } } finally @@ -147,7 +137,7 @@ public void Should_InferDelimiter_When_SniffDialectIsSet() { using CsvReader reader = Excel.FromCsvFile(path, CsvReaderOptions.Default with { SniffDialect = true }); - Assert.Equal(["Ada", "London", "36"], ReadAll(reader)[1]); + Assert.Equal(["Ada", "London", "36"], ReadAll(reader.FirstSheet)[1]); } finally { @@ -164,7 +154,7 @@ public void Should_KeepExplicitEncoding_When_SniffingFindsNoByteOrderMark() var options = CsvReaderOptions.Default with { SniffDialect = true, Encoding = Encoding.Latin1 }; using CsvReader reader = Excel.FromCsvFile(path, options); - Assert.Equal(["José", "São Paulo"], ReadAll(reader)[1]); + Assert.Equal(["José", "São Paulo"], ReadAll(reader.FirstSheet)[1]); } finally { diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs index fce632ef..551122f0 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvLifetimeTests.cs @@ -45,7 +45,7 @@ private static List Drain(CsvReader.Enumerator e) private static List Expected() { using CsvReader reference = Excel.FromCsv(BuildCsv()); - using CsvReader.Enumerator all = reference.GetEnumerator(); + using CsvReader.Enumerator all = reference.FirstSheet.GetEnumerator(); return Drain(all); } @@ -54,8 +54,8 @@ public void Two_Enumerators_Over_One_Seekable_Stream_Do_Not_Share_A_Position() { List expected = Expected(); using CsvReader reader = Excel.FromCsv(new TrickleStream(BuildCsv()), leaveOpen: false); - using CsvReader.Enumerator first = reader.GetEnumerator(); - using CsvReader.Enumerator second = reader.GetEnumerator(); + using CsvReader.Enumerator first = reader.FirstSheet.GetEnumerator(); + using CsvReader.Enumerator second = reader.FirstSheet.GetEnumerator(); List a = []; List b = []; @@ -93,7 +93,7 @@ public void Enumerators_On_Different_Threads_Each_Read_Every_Row(string source) CsvReader.Enumerator[] enumerators = new CsvReader.Enumerator[Threads]; for (int t = 0; t < Threads; t++) { - enumerators[t] = reader.GetEnumerator(); + enumerators[t] = reader.FirstSheet.GetEnumerator(); } List[] actual = new List[Threads]; Parallel.For(0, Threads, new ParallelOptions { MaxDegreeOfParallelism = Threads }, t => @@ -128,8 +128,8 @@ public void A_Transcoded_Stream_Serves_Two_Enumerators() using CsvReader reader = Excel.FromCsv( new TrickleStream(BuildCsv(Encoding.Unicode)), leaveOpen: false, new CsvReaderOptions { Encoding = Encoding.Unicode, DetectEncodingFromByteOrderMark = false }); - using CsvReader.Enumerator first = reader.GetEnumerator(); - using CsvReader.Enumerator second = reader.GetEnumerator(); + using CsvReader.Enumerator first = reader.FirstSheet.GetEnumerator(); + using CsvReader.Enumerator second = reader.FirstSheet.GetEnumerator(); Assert.Equal(expected, Drain(first), StringComparer.Ordinal); Assert.Equal(expected, Drain(second), StringComparer.Ordinal); } @@ -144,8 +144,8 @@ public void A_Stream_Already_Advanced_By_The_Caller_Starts_Every_Enumerator_Ther stream.Position = prefix.Length; using CsvReader reader = Excel.FromCsv(stream, leaveOpen: false); - using CsvReader.Enumerator first = reader.GetEnumerator(); - using CsvReader.Enumerator second = reader.GetEnumerator(); + using CsvReader.Enumerator first = reader.FirstSheet.GetEnumerator(); + using CsvReader.Enumerator second = reader.FirstSheet.GetEnumerator(); Assert.Equal(expected, Drain(first), StringComparer.Ordinal); Assert.Equal(expected, Drain(second), StringComparer.Ordinal); } @@ -163,7 +163,7 @@ public void A_NonSeekable_Stream_Serves_Exactly_One_Enumerator() { try { - taken[t] = reader.GetEnumerator(); + taken[t] = reader.FirstSheet.GetEnumerator(); Interlocked.Increment(ref granted); } catch (InvalidOperationException) @@ -184,7 +184,7 @@ public void An_Enumerator_Outlives_Its_Reader() List expected = Expected(); TrickleStream stream = new(BuildCsv()); CsvReader reader = Excel.FromCsv(stream, leaveOpen: false); - CsvReader.Enumerator e = reader.GetEnumerator(); + CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -199,9 +199,11 @@ public void An_Enumerator_Outlives_Its_Reader() public void GetEnumerator_After_Dispose_Throws() { CsvReader reader = Excel.FromCsv(new TrickleStream(BuildCsv()), leaveOpen: false); + CsvSheet sheet = reader.FirstSheet; reader.Dispose(); - Assert.Throws(() => reader.GetEnumerator()); - Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); + Assert.Throws(() => reader.Sheets); + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Throws(() => sheet.GetAsyncEnumerator(TestContext.Current.CancellationToken)); } [Fact] @@ -224,7 +226,7 @@ public void A_Borrowed_Stream_Is_Left_Open() TrickleStream stream = new(BuildCsv()); using (CsvReader reader = Excel.FromCsv(stream, leaveOpen: true)) { - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); } Assert.True(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs index c13047f2..a0aac49c 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvReaderTests.cs @@ -15,7 +15,7 @@ private static MemoryStream Csv(string content) private static List ReadAll(CsvReader reader) { var rows = new List(); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); while (e.MoveNext()) { var row = e.Current; @@ -32,7 +32,7 @@ private static List ReadAll(CsvReader reader) private static async Task> ReadAllAsync(CsvReader reader) { var rows = new List(); - await using CsvReader.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using CsvReader.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); while (await e.MoveNextAsync()) { rows.Add(ToArray(e)); @@ -43,7 +43,7 @@ private static async Task> ReadAllAsync(CsvReader reader) private static async Task> ReadAllViaAsyncEnumerator(CsvReader reader) { var rows = new List(); - await foreach (var row in reader) + await foreach (var row in reader.FirstSheet) { var cells = new string[row.ColumnCount]; for (int i = 0; i < row.ColumnCount; i++) @@ -145,7 +145,7 @@ public void EmptyFieldsAreEmptyCellType() { using var ms = Csv("a,,c\n"); using var reader = Excel.FromCsv(ms); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; @@ -170,7 +170,7 @@ public void EmptyQuotedFieldIsEmpty() { using var ms = Csv("""a,"",c"""); using var reader = Excel.FromCsv(ms); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; @@ -294,7 +294,7 @@ public void FieldExceedingMaxCellBytesThrows() using var ms = Csv("a," + new string('x', 128 * 1024) + ",c\n"); var options = new CsvReaderOptions { MaxCellBytes = 1024 }; using var reader = Excel.FromCsv(ms, options: options); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); ExcelLimitExceededException ex = Assert.Throws(() => e.MoveNext()); Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); @@ -306,7 +306,7 @@ public void EmptyFieldsExceedingMaxCellBytesThrows() using var ms = Csv(new string(',', 32)); var options = new CsvReaderOptions { MaxCellBytes = 1024 }; using var reader = Excel.FromCsv(ms, options: options); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); ExcelLimitExceededException ex = Assert.Throws(() => e.MoveNext()); Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); @@ -401,7 +401,7 @@ public void NonSeekableStreamThrowsOnSecondEnumeration() var first = ReadAll(reader); Assert.Equal(2, first.Count); - Assert.Throws(reader.GetEnumerator); + Assert.Throws(reader.FirstSheet.GetEnumerator); } @@ -759,7 +759,7 @@ public void ExplicitInterfaceGetEnumeratorWorks() { using var ms = Csv("a,b\n"); using var reader = Excel.FromCsv(ms); - IExcelRowReader ier = reader; + IExcelSheet ier = reader.FirstSheet; using IExcelRowEnumerator e = ier.GetEnumerator(); @@ -771,7 +771,7 @@ public async Task ExplicitInterfaceGetAsyncEnumeratorWorks() { using var ms = Csv("a,b\n"); using var reader = Excel.FromCsv(ms); - IExcelRowReader ier = reader; + IExcelSheet ier = reader.FirstSheet; await using IExcelRowEnumerator e = ier.GetAsyncEnumerator(TestContext.Current.CancellationToken); @@ -783,7 +783,7 @@ public void CurrentRecordStartReportsTheByteOffsetOfEachRecord() { byte[] csv = "ab,cd\r\nef,gh\nij,kl"u8.ToArray(); using var reader = Excel.FromCsv(csv); - using CsvReader.Enumerator rows = reader.GetEnumerator(); + using CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); var starts = new List(); while (rows.MoveNext()) @@ -800,7 +800,7 @@ public async Task CurrentRecordStartMatchesTheSyncPathWhenReadingAsync() byte[] csv = "ab,cd\r\nef,gh\nij,kl"u8.ToArray(); using var ms = new MemoryStream(csv, writable: false); await using var reader = await Excel.FromCsvAsync(ms, ct: TestContext.Current.CancellationToken); - await using CsvReader.Enumerator rows = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using CsvReader.Enumerator rows = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); var starts = new List(); while (await rows.MoveNextAsync()) @@ -827,7 +827,7 @@ public void CurrentRecordStartStaysCorrectWhenRecordsSpanBufferRefills() byte[] csv = System.Text.Encoding.UTF8.GetBytes(sb.ToString()); using var ms = new MemoryStream(csv, writable: false); using var reader = Excel.FromCsv(ms); - using CsvReader.Enumerator rows = reader.GetEnumerator(); + using CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); var actual = new List(); while (rows.MoveNext()) diff --git a/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs b/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs index b9bc3a6b..b4620e2a 100644 --- a/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs +++ b/tests/ExcelReader.Tests/Reader/Csv/CsvSnifferTests.cs @@ -178,7 +178,7 @@ public void SniffedDialectRoundTripsThroughReader() CsvDialect dialect = CsvSniffer.Detect(data); CsvReaderOptions options = CsvReaderOptions.Default.WithDialect(dialect); using var reader = Excel.FromCsv(data, options); - using CsvReader.Enumerator e = reader.GetEnumerator(); + using CsvReader.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("nome", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs b/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs index 7234b59b..e3768c9a 100644 --- a/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs +++ b/tests/ExcelReader.Tests/Reader/Internal/SharedStringCacheTests.cs @@ -27,7 +27,7 @@ private static byte[] BuildXlsx() private static List FirstColumn(XlsxWorkbook reader) { List values = []; - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); while (e.MoveNext()) { values.Add(e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs index 0e54fd88..8167b10e 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsLifetimeTests.cs @@ -40,9 +40,11 @@ private static List Drain(XlsWorkbook.Enumerator e) public void GetEnumerator_After_Dispose_Throws() { XlsWorkbook reader = Excel.FromXls(BuildXls()); + XlsSheet sheet = reader.FirstSheet; reader.Dispose(); - Assert.Throws(() => reader.GetEnumerator()); - Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); + Assert.Throws(() => reader.Sheets); + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Throws(() => sheet.GetAsyncEnumerator(TestContext.Current.CancellationToken)); } [Fact] @@ -52,13 +54,13 @@ public void An_Enumerator_Outlives_Its_Reader() List expected; using (XlsWorkbook reference = Excel.FromXls(bytes)) { - using XlsWorkbook.Enumerator all = reference.GetEnumerator(); + using XlsWorkbook.Enumerator all = reference.FirstSheet.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); - XlsWorkbook.Enumerator e = reader.GetEnumerator(); + XlsWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -76,7 +78,7 @@ public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXls(rows: 20)); XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); - XlsWorkbook.Enumerator e = reader.GetEnumerator(); + XlsWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs index cced2be5..aff305a6 100644 --- a/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xls/XlsWorkbookTests.cs @@ -1,6 +1,7 @@ using System.Collections; using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xls; using ExcelReader.Core.Writer.Xls; namespace ExcelReader.Tests.Reader.Xls @@ -24,7 +25,7 @@ public void ReadsBasicBiff8Cells() sheets: [("S1", [["Name", "Age", "Active", "Err", "Formula"], ["João", 42, true, new XlsError(0x07), new XlsFormula(12.5)]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); RowAssert(e.Current, ["Name", "Age", "Active", "Err", "Formula"]); Assert.True(e.MoveNext()); @@ -56,7 +57,7 @@ public void ReadsAdditionalBiff8CellEncodings() ]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.Equal("Unicode Ω", row[0].GetString()); @@ -88,7 +89,7 @@ public void ReadsWideRowsRkDoublesAndAllErrorNames() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [wide])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.Equal(45, row.ColumnCount); @@ -107,7 +108,7 @@ public void OutOfOrderCellsAreSortedBeforeRowIsExposed() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [[new XlsAt(2, "C"), new XlsAt(0, "A"), new XlsAt(1, "B")]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); Assert.Equal("B", e.Current[1].GetString()); @@ -125,7 +126,7 @@ public void OutOfOrderNumericAndTextCellsStayAlignedAfterSort() ]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; Assert.True(row[0].TryGetDouble(out double v0)); @@ -148,14 +149,12 @@ public void ReadsSharedStringsAndMultipleSheets() using var reader = Excel.FromXls(ms); Assert.Equal(2, reader.SheetCount); - Assert.True(reader.TryMoveToSheet("second")); - Assert.Equal("Second", reader.SheetName); - reader.MoveToSheet(0); - Assert.Equal("First", reader.SheetName); - reader.MoveToSheet(1); - Assert.Equal("Second", reader.SheetName); - - using var e = reader.GetEnumerator(); + Assert.True(reader.TryGetSheet("second", out XlsSheet second)); + Assert.Equal("Second", second.Name); + Assert.Equal("First", reader.Sheets[0].Name); + Assert.Equal("Second", reader.Sheets[1].Name); + + using var e = reader.Sheets[1].GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Header", e.Current[0].GetString()); Assert.True(e.MoveNext()); @@ -169,7 +168,7 @@ public void LargeSharedStringGrowsSharedBuffer() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [[new XlsSharedString(text)]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(text, e.Current[0].GetString()); } @@ -180,8 +179,8 @@ public void UnicodeSheetNamesAreDecoded() using var ms = XlsWorkbookBuilder.Build(sheets: [("Ωmega", [["A"]])]); using var reader = Excel.FromXls(ms); - Assert.Equal("Ωmega", reader.SheetName); - Assert.True(reader.TryMoveToSheet("ωmega")); + Assert.Equal("Ωmega", reader.Sheets[0].Name); + Assert.True(reader.TryGetSheet("ωmega", out _)); } [Fact] @@ -194,7 +193,7 @@ public void Date1904IsAppliedByParser() using var reader = Excel.FromXls(ms); Assert.True(reader.IsDate1904); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(date, result[0].BirthDate); } @@ -208,7 +207,7 @@ public void CustomDateFormatMarksDateCells() sheets: [("S1", [["BirthDate"], [new XlsDate(date)]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -224,7 +223,7 @@ public void CustomNonDateFormatKeepsNumberCells() sheets: [("S1", [["BirthDate"], [new XlsDate(new DateTime(2024, 5, 6, 0, 0, 0, DateTimeKind.Unspecified))]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -243,7 +242,7 @@ public void CustomFormatWithoutDateTokensKeepsNumberCells(string format) sheets: [("S1", [["BirthDate"], [new XlsDate(new DateTime(2024, 5, 6, 0, 0, 0, DateTimeKind.Unspecified))]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -257,7 +256,7 @@ public void UnicodeCustomDateFormatMarksDateCells() sheets: [("S1", [["BirthDate"], [new XlsDate(new DateTime(2024, 5, 6, 0, 0, 0, DateTimeKind.Unspecified))]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -270,7 +269,7 @@ public void TypedParserUsesXlsWorkbookAndAttributeAliases() sheets: [("S1", [["Name", "Age", "Active"], ["Ana", 31, false]])]); using var reader = Excel.FromXls(ms); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Ana", result[0].Name); Assert.Equal(31, result[0].Age); @@ -282,7 +281,7 @@ public void XlsParserEnumerableSupportsExplicitInterfacesAndResetThrows() { using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["Name"], ["Lua"]])]); using var reader = Excel.FromXls(ms); - var enumerable = ExcelParser.FromAttributes().Parse(reader); + var enumerable = ExcelParser.FromAttributes().Parse(reader.FirstSheet); using IEnumerator generic = ((IEnumerable)enumerable).GetEnumerator(); Assert.True(generic.MoveNext()); @@ -308,7 +307,7 @@ public void XlsParserHeaderRowSkipsLeadingRows() using var reader = Excel.FromXls(ms); var parser = ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = 2 }); - var result = parser.Parse(reader).ToList(); + var result = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Sol", result[0].Name); } @@ -319,7 +318,7 @@ public void XlsParserReturnsDefaultRowsWhenHeaderMapsNoColumns() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["Unknown"], ["value"]])]); using var reader = Excel.FromXls(ms); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].Name); } @@ -331,7 +330,7 @@ public async Task XlsAsyncParserReturnsDefaultRowsWhenHeaderMapsNoColumns() using var reader = Excel.FromXls(ms); var rows = new List(); - await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { rows.Add(row); } @@ -348,7 +347,7 @@ public async Task AsyncReaderAndParserMatchSync() await using var reader = await Excel.FromXlsAsync(ms, ct: TestContext.Current.CancellationToken); List rows = []; - await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { rows.Add(row); } @@ -365,7 +364,7 @@ public async Task AsyncParserDisposeBeforeMoveNextIsNoop() using var reader = Excel.FromXls(ms); IAsyncEnumerator e = ExcelParser.FromAttributes() - .Parse(reader) + .Parse(reader.FirstSheet) .GetAsyncEnumerator(TestContext.Current.CancellationToken); Exception? ex = await Record.ExceptionAsync(async () => @@ -382,7 +381,7 @@ public void CancelledAsyncEnumeratorThrows() using var cts = new CancellationTokenSource(); cts.Cancel(); - Assert.Throws(() => reader.GetAsyncEnumerator(cts.Token)); + Assert.Throws(() => reader.FirstSheet.GetAsyncEnumerator(cts.Token)); } [Fact] @@ -392,7 +391,7 @@ public void RepeatedSharedStringDedupsIntoSameInstance() sheets: [("S1", [[new XlsSharedString("Repeat"), new XlsSharedString("Repeat")]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Same(e.Current[0].GetString(), e.Current[1].GetString()); } @@ -403,7 +402,7 @@ public void SharedStringIndexOutsideTableYieldsEmptyString() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [[new XlsSharedIndex(99)]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(string.Empty, e.Current[0].GetString()); } @@ -418,14 +417,14 @@ public async Task FileFactoryMethodsOpenXlsFiles() using (var reader = Excel.FromXlsFile(path)) { - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); } await using (var reader = await Excel.FromXlsFileAsync(path, ct: TestContext.Current.CancellationToken)) { - await using var e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using var e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal("A", e.Current[0].GetString()); } @@ -445,9 +444,9 @@ public void SheetNavigationValidatesIndexAndMissingName() using var ms = XlsWorkbookBuilder.Build(sheets: [("S1", [["A"]])]); using var reader = Excel.FromXls(ms); - Assert.False(reader.TryMoveToSheet("Missing")); - Assert.Throws(() => reader.MoveToSheet(-1)); - Assert.Throws(() => reader.MoveToSheet(1)); + Assert.False(reader.TryGetSheet("Missing", out _)); + Assert.Throws(() => reader.Sheets[-1]); + Assert.Throws(() => reader.Sheets[1]); } [Fact] @@ -496,7 +495,7 @@ public void BadWorksheetBofThrowsWhenEnumerated() { using var ms = XlsWorkbookBuilder.BuildBadSheetBof(); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.Throws(() => e.MoveNext()); } @@ -514,7 +513,7 @@ public void ShortAndUnknownSheetRecordsAreSkippedSafely() (0x0006, XlsWorkbookBuilder.RawRowOnly(0)), (0x0204, XlsWorkbookBuilder.RawLabel(0, 0, "A"))); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); @@ -536,7 +535,7 @@ public void ReadsManyRowsSpanningMultipleSectorsWithExactCount() Assert.True(ms.Length > SectorSize * 4, "workbook should span several OLE sectors"); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); RowAssert(e.Current, ["Name", "Age", "Score"]); @@ -562,7 +561,7 @@ public void NumericCellsExposeRawDoubleMatchingFormattedText() sheets: [("S1", [[12.5, 42, new XlsRkInt(123), -7.25]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var row = e.Current; @@ -592,7 +591,7 @@ public void SharedStringSplitAcrossContinueBoundaryDecodesCorrectly() using var ms = XlsWorkbookBuilder.BuildRawSst(framed, labelSstCount: 2); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("AB", e.Current[0].GetString()); Assert.Equal("CDEF", e.Current[1].GetString()); @@ -608,7 +607,7 @@ public void WideSharedStringCodeUnitSplitAcrossContinueBoundaryDecodesCorrectly( using var ms = XlsWorkbookBuilder.BuildRawSst(framed, labelSstCount: 1); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("AΩB", e.Current[0].GetString()); } @@ -621,7 +620,7 @@ public void AstralCharInUnicodeStringRoundTripsAsValidUtf8() sheets: [("S1", [[new XlsUnicodeString(emoji)]])]); using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(emoji, e.Current[0].GetString()); } @@ -655,7 +654,7 @@ public async Task WritesAndReadsLongLabelSplitsAcrossContinueRecords() ms.Position = 0; using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(longCompressed, e.Current[0].GetString()); Assert.Equal(longWide, e.Current[1].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs index 7973038e..912451a9 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbLifetimeTests.cs @@ -40,9 +40,11 @@ private static List Drain(XlsbWorkbook.Enumerator e) public void GetEnumerator_After_Dispose_Throws() { XlsbWorkbook reader = Excel.FromXlsb(BuildXlsb()); + XlsbSheet sheet = reader.FirstSheet; reader.Dispose(); - Assert.Throws(() => reader.GetEnumerator()); - Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); + Assert.Throws(() => reader.Sheets); + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Throws(() => sheet.GetAsyncEnumerator(TestContext.Current.CancellationToken)); } [Fact] @@ -52,13 +54,13 @@ public void An_Enumerator_Outlives_Its_Reader() List expected; using (XlsbWorkbook reference = Excel.FromXlsb(bytes)) { - using XlsbWorkbook.Enumerator all = reference.GetEnumerator(); + using XlsbWorkbook.Enumerator all = reference.FirstSheet.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); XlsbWorkbook reader = Excel.FromXlsb(stream, leaveOpen: false); - XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -76,7 +78,7 @@ public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXlsb(rows: 20)); XlsbWorkbook reader = Excel.FromXlsb(stream, leaveOpen: false); - XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbParserTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbParserTests.cs index 9e2832a4..1415b63c 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbParserTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbParserTests.cs @@ -22,7 +22,7 @@ public void ParserMapsXlsbRows() using var ms = BuildWorkbook(); using var reader = Excel.FromXlsb(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("Alice", rows[0].Name); @@ -37,7 +37,7 @@ public async Task AsyncParserMapsXlsbRows() await using var reader = await Excel.FromXlsbAsync(ms, ct: TestContext.Current.CancellationToken); var rows = new List(); - await foreach (var row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (var row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { rows.Add(row); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs index 01882b5b..b2456ae8 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsb/XlsbStructureTests.cs @@ -95,7 +95,7 @@ public async Task RowCrossingColumnBlocksGetsOneSpanPerBlock() AssertRowsCovered(structure); using XlsbWorkbook reader = Excel.FromXlsb(new MemoryStream(bytes)); var values = new List(); - foreach (var r in reader) + foreach (var r in reader.FirstSheet) { values.Add(r[0].GetString()); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs index 4d3a0267..ac84af2f 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/MemoryZipParityTests.cs @@ -56,8 +56,8 @@ public async Task MemoryAsyncEnumeratorNeverSuspends(MemoryFixture fixture) { CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = fixture.Build(); - using IExcelRowReader reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(ct); + using IExcelWorkbook reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); ValueTask task = e.MoveNextAsync(); Assert.True(task.IsCompleted); Assert.True(await task); @@ -79,7 +79,7 @@ public void PrefetchDecompressionProducesIdenticalOutput(MemoryFixture fixture) [Fact] - public void MoveToSheetAndTryMoveToSheetWorkOnTheMemoryPath() + public void SheetsAndTryGetSheetWorkOnTheMemoryPath() { using MemoryStream built = WorkbookBuilder.BuildMultiSheet( sheets: @@ -92,23 +92,22 @@ public void MoveToSheetAndTryMoveToSheetWorkOnTheMemoryPath() using XlsxWorkbook reader = Excel.FromXlsx(bytes.AsMemory()); Assert.Equal(2, reader.SheetCount); - reader.MoveToSheet(1); - Assert.Equal("Second", reader.SheetName); - using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) + Assert.Equal("Second", reader.Sheets[1].Name); + using (XlsxWorkbook.Enumerator e = reader.Sheets[1].GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("2", e.Current[0].GetString()); } - Assert.True(reader.TryMoveToSheet("First")); - Assert.Equal("First", reader.SheetName); - using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) + Assert.True(reader.TryGetSheet("First", out XlsxSheet first)); + Assert.Equal("First", first.Name); + using (XlsxWorkbook.Enumerator e = first.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); } - Assert.False(reader.TryMoveToSheet("NoSuchSheet")); + Assert.False(reader.TryGetSheet("NoSuchSheet", out _)); } @@ -120,7 +119,7 @@ public void ReadsCorrectlyOverANonArrayBackedMemory() var manager = new NonArrayMemoryManager(bytes); using XlsxWorkbook reader = Excel.FromXlsx(manager.Memory); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("42", e.Current[0].GetString()); } @@ -131,7 +130,7 @@ public void OpenMemoryDetectsXlsx() { using MemoryStream built = WorkbookBuilder.Build("""1"""); byte[] bytes = built.ToArray(); - using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); + using IExcelWorkbook reader = Excel.Open(bytes.AsMemory()); Assert.IsType(reader); } @@ -140,7 +139,7 @@ public async Task OpenMemoryDetectsXlsb() { CancellationToken ct = TestContext.Current.CancellationToken; byte[] bytes = await BuildXlsbAsync(ct); - using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); + using IExcelWorkbook reader = Excel.Open(bytes.AsMemory()); Assert.IsType(reader); } @@ -149,7 +148,7 @@ public void OpenMemoryDetectsXls() { using MemoryStream built = XlsWorkbookBuilder.Build(sheets: [("S1", [["Name", 1, true]])]); byte[] bytes = built.ToArray(); - using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); + using IExcelWorkbook reader = Excel.Open(bytes.AsMemory()); Assert.IsType(reader); } @@ -165,8 +164,8 @@ public void OpenMemoryThrowsInvalidDataExceptionForUnknownSignature() public void OpenMemoryMatchesOpenStreamForAutoDetection(MemoryFixture fixture) { byte[] bytes = fixture.Build(); - using IExcelRowReader streamed = Excel.Open(new MemoryStream(bytes, writable: false)); - using IExcelRowReader memory = Excel.Open(bytes.AsMemory()); + using IExcelWorkbook streamed = Excel.Open(new MemoryStream(bytes, writable: false)); + using IExcelWorkbook memory = Excel.Open(bytes.AsMemory()); Assert.Equal(streamed.GetType(), memory.GetType()); } @@ -176,7 +175,7 @@ public void OpenMemoryMatchesOpenStreamForAutoDetection(MemoryFixture fixture) public void DoubleDisposeIsSafe(MemoryFixture fixture) { byte[] bytes = fixture.Build(); - IExcelRowReader reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); + IExcelWorkbook reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); reader.Dispose(); Assert.Null(Record.Exception(reader.Dispose)); } @@ -186,8 +185,8 @@ public void DoubleDisposeIsSafe(MemoryFixture fixture) public void DoubleDisposingTheEnumeratorIsSafe(MemoryFixture fixture) { byte[] bytes = fixture.Build(); - using IExcelRowReader reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = fixture.OpenMemory(bytes, ExcelReaderOptions.Default); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); e.Dispose(); Assert.Null(Record.Exception(e.Dispose)); } @@ -224,12 +223,12 @@ public void MissingWorksheetPartThrowsInvalidDataExceptionOnBothPaths() Assert.Throws(() => { using XlsxWorkbook reader = Excel.FromXlsx(new MemoryStream(withoutSheet)); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); }); Assert.Throws(() => { using XlsxWorkbook reader = Excel.FromXlsx(withoutSheet.AsMemory()); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); }); } @@ -245,8 +244,8 @@ public void CorruptedStoredEntryReadsTheSameOnStreamAndMemoryPaths(bool xlsx) int at = corrupted.AsSpan().IndexOf(needle); Assert.True(at >= 0); corrupted[at] = (byte)'j'; - Func openStream = xlsx ? OpenXlsxStream : OpenXlsbStream; - Func openMemory = xlsx ? OpenXlsxMemory : OpenXlsbMemory; + Func openStream = xlsx ? OpenXlsxStream : OpenXlsbStream; + Func openMemory = xlsx ? OpenXlsxMemory : OpenXlsbMemory; string streamed = Outcome(() => ReadViaStream(corrupted, openStream)); string memory = Outcome(() => ReadViaMemory(corrupted, openMemory)); @@ -332,11 +331,10 @@ public void MutatedZipBytesNeverCrashExcelOpenMemory() private static void OpenAndDrainMemory(byte[] bytes) { - using IExcelRowReader reader = Excel.Open(bytes.AsMemory()); + using IExcelWorkbook reader = Excel.Open(bytes.AsMemory()); for (int s = 0; s < reader.SheetCount; s++) { - reader.MoveToSheet(s); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.SheetAt(s).GetEnumerator(); while (e.MoveNext()) { for (int c = 0; c < e.Current.ColumnCount; c++) @@ -362,11 +360,11 @@ public void RealSampleWorkbookMatchesBetweenStreamAndMemory() } - private static List ReadViaStream(byte[] bytes, Func open) + private static List ReadViaStream(byte[] bytes, Func open) { using MemoryStream stream = new(bytes, writable: false); - using IExcelRowReader reader = open(stream, ExcelReaderOptions.Default); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = open(stream, ExcelReaderOptions.Default); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); List cells = []; int rowIndex = 0; while (e.MoveNext()) @@ -377,10 +375,10 @@ private static List ReadViaStream(byte[] bytes, Func ReadViaMemory( - byte[] bytes, Func open, ExcelReaderOptions? options = null) + byte[] bytes, Func open, ExcelReaderOptions? options = null) { - using IExcelRowReader reader = open(bytes, options ?? ExcelReaderOptions.Default); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = open(bytes, options ?? ExcelReaderOptions.Default); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); List cells = []; int rowIndex = 0; while (e.MoveNext()) @@ -391,10 +389,10 @@ private static List ReadViaMemory( } private static async Task> ReadViaMemoryAsync( - byte[] bytes, Func open, CancellationToken ct) + byte[] bytes, Func open, CancellationToken ct) { - using IExcelRowReader reader = open(bytes, ExcelReaderOptions.Default); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(ct); + using IExcelWorkbook reader = open(bytes, ExcelReaderOptions.Default); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); List cells = []; int rowIndex = 0; while (await e.MoveNextAsync()) @@ -462,26 +460,26 @@ private static async Task BuildXlsbAsync(CancellationToken ct) } - private static IExcelRowReader OpenXlsxStream(Stream stream, ExcelReaderOptions options) + private static IExcelWorkbook OpenXlsxStream(Stream stream, ExcelReaderOptions options) { return Excel.FromXlsx(stream, options: options); } - private static IExcelRowReader OpenXlsxMemory(byte[] bytes, ExcelReaderOptions options) + private static IExcelWorkbook OpenXlsxMemory(byte[] bytes, ExcelReaderOptions options) { return Excel.FromXlsx(bytes.AsMemory(), options); } [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", - Justification = "Must match the Func delegate shape shared with the XLSX fixture.")] - private static IExcelRowReader OpenXlsbStream(Stream stream, ExcelReaderOptions options) + Justification = "Must match the Func delegate shape shared with the XLSX fixture.")] + private static IExcelWorkbook OpenXlsbStream(Stream stream, ExcelReaderOptions options) { return Excel.FromXlsb(stream, options: options); } [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", - Justification = "Must match the Func delegate shape shared with the XLSX fixture.")] - private static IExcelRowReader OpenXlsbMemory(byte[] bytes, ExcelReaderOptions options) + Justification = "Must match the Func delegate shape shared with the XLSX fixture.")] + private static IExcelWorkbook OpenXlsbMemory(byte[] bytes, ExcelReaderOptions options) { return Excel.FromXlsb(bytes.AsMemory(), options); } @@ -525,8 +523,8 @@ private static void RemoveZipEntry(byte[] zipBytes, string entryName, out byte[] public sealed record MemoryFixture( string Name, Func Build, - Func OpenStream, - Func OpenMemory) + Func OpenStream, + Func OpenMemory) { public override string ToString() { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs index e59b05d2..ea69a267 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/NamespacePrefixAndIsoDateTests.cs @@ -12,7 +12,7 @@ public void PrefixedNumberAndInlineStringCellsAreRead() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """42hello"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -28,7 +28,7 @@ public void PrefixedMultipleRowsAreAllEnumerated() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """123"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); @@ -46,7 +46,7 @@ public void PrefixedSharedStringsAreResolved() """01""", sharedStrings: "alphabeta"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("alpha", e.Current[0].GetString()); @@ -60,7 +60,7 @@ public void PrefixedPhoneticRunsAreSkippedInSharedStrings() """0""", sharedStrings: "株式会社カブシキガイシャ"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("株式会社", e.Current[0].GetString()); @@ -73,7 +73,7 @@ public void PrefixedCustomDateStyleIsClassifiedAsDate() """45658""", stylesInner: """"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -88,7 +88,7 @@ public async Task PrefixedWorksheetIsReadAsync() """70""", sharedStrings: "async"); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal("7", e.Current[0].GetString()); @@ -102,7 +102,7 @@ public void UnusualPrefixNameIsSupported() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("ss", """99"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("99", e.Current[0].GetString()); @@ -115,7 +115,7 @@ public void IsoDateTimeCellIsParsedAsDate() using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02T13:45:00"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -129,7 +129,7 @@ public void IsoDateOnlyCellIsParsedAsDate() using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -143,7 +143,7 @@ public void UnparseableIsoDateCellIsKeptAsString() using MemoryStream ms = WorkbookBuilder.Build( """not-a-date"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.ExcelString, e.Current[0].Type); @@ -159,7 +159,7 @@ public void IsoDateBelowOaDateRangeIsKeptAsString(string text) using MemoryStream ms = WorkbookBuilder.Build( $"""{text}"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.ExcelString, e.Current[0].Type); @@ -172,7 +172,7 @@ public void IsoDateAtOaDateFloorIsStillParsedAsDate() using MemoryStream ms = WorkbookBuilder.Build( """0100-01-01"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -184,7 +184,7 @@ public async Task IsoDateCellIsParsedAsDateAsync() await using MemoryStream ms = WorkbookBuilder.Build( """2026-01-02T00:00:00"""); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -198,7 +198,7 @@ public void IsoDateCellCombinedWithPrefix() using MemoryStream ms = WorkbookBuilder.BuildPrefixed("x", """2026-03-04"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs index 5ac1cf2e..7c9b1f2c 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/PrefetchDecompressionTests.cs @@ -69,8 +69,8 @@ public Task DisposingAfterOneRowCompletesPromptlySync(PrefetchFixture fixture) Task work = Task.Run(() => { using MemoryStream stream = new(bytes, writable: false); - using IExcelRowReader reader = fixture.OpenSync(stream, options); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = fixture.OpenSync(stream, options); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); }, ct); @@ -90,8 +90,8 @@ public Task DisposingAfterOneRowCompletesPromptlyAsync(PrefetchFixture fixture) Task work = Task.Run(async () => { await using MemoryStream stream = new(bytes, writable: false); - await using IExcelRowReader reader = await fixture.OpenAsync(stream, options, ct); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(ct); + await using IExcelWorkbook reader = await fixture.OpenAsync(stream, options, ct); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); Assert.True(await e.MoveNextAsync()); }, ct); @@ -140,10 +140,10 @@ private static Type CaptureSyncExceptionType(byte[] bytes, bool prefetch) { var options = new ExcelReaderOptions { PrefetchDecompression = prefetch }; using MemoryStream stream = new(bytes, writable: false); - using IExcelRowReader reader = Excel.FromXlsx(stream, options: options); + using IExcelWorkbook reader = Excel.FromXlsx(stream, options: options); Exception ex = Assert.Throws(() => { - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); DrainRows(e); }); return ex.GetType(); @@ -156,7 +156,7 @@ private static async Task CaptureAsyncExceptionTypeAsync(byte[] bytes, boo await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: ct); Exception ex = await Assert.ThrowsAsync(async () => { - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); await DrainRowsAsync(e); }); return ex.GetType(); @@ -200,7 +200,7 @@ public void PrefetchOnStillTripsTotalDecompressedLimitSync() using XlsxWorkbook reader = Excel.FromXlsx(ms, options: options); ExcelLimitExceededException ex = Assert.Throws(() => { - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); DrainRows(e); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -222,7 +222,7 @@ public async Task PrefetchOnStillTripsTotalDecompressedLimitAsync() await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, options: options, ct: ct); ExcelLimitExceededException ex = await Assert.ThrowsAsync(async () => { - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); await DrainRowsAsync(e); }); Assert.Equal(nameof(ExcelReaderOptions.MaxTotalDecompressedBytes), ex.LimitName); @@ -241,7 +241,7 @@ public async Task AlreadyCancelledTokenThrowsWithPrefetchOn() await using MemoryStream stream = new(bytes, writable: false); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); await Assert.ThrowsAnyAsync(async () => await e.MoveNextAsync()); } @@ -256,7 +256,7 @@ public async Task CancellingMidEnumerationStopsPromptlyWithPrefetchOn() await using MemoryStream stream = new(bytes, writable: false); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, options: options, ct: openCt); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(cts.Token); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync()); cts.Cancel(); @@ -308,12 +308,12 @@ private static Stream GetInnerStream(LimitedReadStream stream) private static List ReadSync( byte[] bytes, - Func open, + Func open, ExcelReaderOptions options) { using MemoryStream stream = new(bytes, writable: false); - using IExcelRowReader reader = open(stream, options); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = open(stream, options); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); List cells = []; int rowIndex = 0; while (e.MoveNext()) @@ -325,13 +325,13 @@ private static List ReadSync( private static async Task> ReadAsync( byte[] bytes, - Func> open, + Func> open, ExcelReaderOptions options, CancellationToken ct) { await using MemoryStream stream = new(bytes, writable: false); - await using IExcelRowReader reader = await open(stream, options, ct); - await using IExcelRowEnumerator e = reader.GetAsyncEnumerator(ct); + await using IExcelWorkbook reader = await open(stream, options, ct); + await using IExcelRowEnumerator e = reader.FirstSheet.GetAsyncEnumerator(ct); List cells = []; int rowIndex = 0; while (await e.MoveNextAsync()) @@ -412,12 +412,12 @@ private static XlsbWorkbook OpenXlsb(Stream stream, ExcelReaderOptions options) return Excel.FromXlsb(stream, options: options); } - private static async ValueTask OpenXlsxAsync(Stream stream, ExcelReaderOptions options, CancellationToken ct) + private static async ValueTask OpenXlsxAsync(Stream stream, ExcelReaderOptions options, CancellationToken ct) { return await Excel.FromXlsxAsync(stream, options: options, ct: ct); } - private static async ValueTask OpenXlsbAsync(Stream stream, ExcelReaderOptions options, CancellationToken ct) + private static async ValueTask OpenXlsbAsync(Stream stream, ExcelReaderOptions options, CancellationToken ct) { return await Excel.FromXlsbAsync(stream, options: options, ct: ct); } @@ -467,8 +467,8 @@ private static void ScrambleCompressedData(byte[] zipBytes, int centralDirectory public sealed record PrefetchFixture( string Name, Func Build, - Func OpenSync, - Func> OpenAsync) + Func OpenSync, + Func> OpenAsync) { public override string ToString() { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs index dce20b82..0516f401 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/RealWorldXlsxCorpusTests.cs @@ -11,7 +11,7 @@ public void ReadsSheetJsGeneratedWorkbook() { string path = Path.Combine(AppContext.BaseDirectory, "data", "sheetjs-sample.xlsx"); using XlsxWorkbook reader = Excel.FromXlsxFile(path); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("name", e.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs index 9f0ca09f..25ca20e9 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxDialectShapeTests.cs @@ -126,7 +126,7 @@ public void SyncReaderHandlesCorpusFixture(CorpusFixture fixture) using XlsxWorkbook reader = Excel.FromXlsx(ms); Assert.NotEmpty(fixture.Expected); - AssertExpected(reader.GetEnumerator(), fixture.Expected); + AssertExpected(reader.FirstSheet.GetEnumerator(), fixture.Expected); } [Theory] @@ -136,7 +136,7 @@ public async Task AsyncReaderHandlesCorpusFixture(CorpusFixture fixture) CancellationToken ct = TestContext.Current.CancellationToken; await using MemoryStream ms = WorkbookBuilder.Build(fixture.Rows, fixture.SharedStrings); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - await using XlsxWorkbook.Enumerator rows = reader.GetAsyncEnumerator(ct); + await using XlsxWorkbook.Enumerator rows = reader.FirstSheet.GetAsyncEnumerator(ct); Assert.NotEmpty(fixture.Expected); await AssertExpectedAsync(rows, fixture.Expected); diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs index 6de541b1..b4d9c13c 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxLifetimeTests.cs @@ -40,8 +40,8 @@ private static List Drain(XlsxWorkbook.Enumerator e) public void A_Failed_Shared_String_Load_Fails_Every_Later_Enumeration() { using XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx(), new ExcelReaderOptions { MaxSharedStringBytes = 16 }); - Assert.Throws(() => reader.GetEnumerator()); - Assert.Throws(() => reader.GetEnumerator()); + Assert.Throws(() => reader.FirstSheet.GetEnumerator()); + Assert.Throws(() => reader.FirstSheet.GetEnumerator()); } [Fact] @@ -51,7 +51,7 @@ public async Task A_Failed_Shared_String_Load_Fails_Every_Later_Async_Enumeratio new MemoryStream(BuildXlsx(), writable: false), leaveOpen: false, new ExcelReaderOptions { MaxSharedStringBytes = 16 }); for (int attempt = 0; attempt < 2; attempt++) { - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => await e.MoveNextAsync()); } } @@ -60,9 +60,11 @@ public async Task A_Failed_Shared_String_Load_Fails_Every_Later_Async_Enumeratio public void GetEnumerator_After_Dispose_Throws() { XlsxWorkbook reader = Excel.FromXlsx(BuildXlsx()); + XlsxSheet sheet = reader.FirstSheet; reader.Dispose(); - Assert.Throws(() => reader.GetEnumerator()); - Assert.Throws(() => reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)); + Assert.Throws(() => reader.Sheets); + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Throws(() => sheet.GetAsyncEnumerator(TestContext.Current.CancellationToken)); } [Fact] @@ -72,13 +74,13 @@ public void An_Enumerator_Outlives_Its_Reader() List expected; using (XlsxWorkbook reference = Excel.FromXlsx(bytes)) { - using XlsxWorkbook.Enumerator all = reference.GetEnumerator(); + using XlsxWorkbook.Enumerator all = reference.FirstSheet.GetEnumerator(); expected = Drain(all); } TrickleStream stream = new(bytes); XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); reader.Dispose(); @@ -96,7 +98,7 @@ public void Disposing_Reader_And_Enumerator_Twice_Is_Harmless() { TrickleStream stream = new(BuildXlsx(rows: 20)); XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); e.Dispose(); e.Dispose(); Assert.True(stream.CanRead); @@ -110,7 +112,7 @@ public void A_Failed_GetEnumerator_Does_Not_Keep_The_Reader_Alive() { TrickleStream stream = new(BuildXlsx(rows: 20)); XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false, new ExcelReaderOptions { MaxSharedStringBytes = 16 }); - Assert.Throws(() => reader.GetEnumerator()); + Assert.Throws(() => reader.FirstSheet.GetEnumerator()); reader.Dispose(); Assert.False(stream.CanRead); } @@ -120,7 +122,7 @@ public async Task A_Disposed_Deferred_Enumerator_Does_Not_Reopen_Its_Sheet() { TrickleStream stream = new(BuildXlsx(rows: 20)); XlsxWorkbook reader = Excel.FromXlsx(stream, leaveOpen: false); - XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); await e.DisposeAsync(); Task move = Task.Run(async () => await e.MoveNextAsync(), TestContext.Current.CancellationToken); Assert.Same(move, await Task.WhenAny(move, Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken))); @@ -136,7 +138,7 @@ public async Task A_Cancelled_Shared_String_Load_Is_Undone_And_Loads_Again() List expected; using (XlsxWorkbook reference = Excel.FromXlsx(bytes)) { - using XlsxWorkbook.Enumerator all = reference.GetEnumerator(); + using XlsxWorkbook.Enumerator all = reference.FirstSheet.GetEnumerator(); expected = Drain(all); } @@ -159,14 +161,14 @@ public async Task A_Cancelled_Shared_String_Load_Is_Undone_And_Loads_Again() } }; - await using (XlsxWorkbook.Enumerator cancelled = reader.GetAsyncEnumerator(cts.Token)) + await using (XlsxWorkbook.Enumerator cancelled = reader.FirstSheet.GetAsyncEnumerator(cts.Token)) { await Assert.ThrowsAnyAsync(async () => await cancelled.MoveNextAsync()); } Assert.InRange(cancelledAt, bodyStart, bodyEnd - 1); List actual = []; - await using (XlsxWorkbook.Enumerator again = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken)) + await using (XlsxWorkbook.Enumerator again = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken)) { while (await again.MoveNextAsync()) { diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs index 08ddd532..566dfe0b 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxProducerDialectShapeTests.cs @@ -154,7 +154,7 @@ public async Task ReaderHandlesProducerShapedXlsxFixtures(ProducerFixture fixtur await using XlsxWorkbook reader = await Excel.FromXlsxAsync( workbook, ct: TestContext.Current.CancellationToken); - await using XlsxWorkbook.Enumerator rows = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator rows = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(rows.MoveNext()); foreach (ExpectedCell expected in fixture.Expected) @@ -320,7 +320,7 @@ public async Task XlsWriterEmitsOleCompoundWorkbookStream() Assert.Equal(unchecked((int)0xFFFFFFFF), BitConverter.ToInt32(bytes, directoryOffset + 128 + 76)); using var reader = Excel.FromXls(new MemoryStream(bytes)); - Assert.Equal("Legacy", reader.SheetName); + Assert.Equal("Legacy", reader.Sheets[0].Name); } private static MemoryStream BuildProducerFixture(ProducerFixture fixture) diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs index e1f36ac8..141ada61 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxWorkbookTests.cs @@ -20,8 +20,7 @@ public void MultiSheetSelectionByIndexReadsDistinctData() Assert.Equal(3, reader.SheetCount); for (int i = 0; i < reader.SheetCount; i++) { - reader.MoveToSheet(i); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.Sheets[i].GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal((i + 1).ToString(CultureInfo.InvariantCulture), e.Current[0].GetString()); } @@ -37,9 +36,9 @@ public void MultiSheetSelectionByNameIsCaseInsensitive() ]); using XlsxWorkbook reader = Excel.FromXlsx(ms); - Assert.True(reader.TryMoveToSheet("beta")); - Assert.Equal("Beta", reader.SheetName); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + Assert.True(reader.TryGetSheet("beta", out XlsxSheet beta)); + Assert.Equal("Beta", beta.Name); + using XlsxWorkbook.Enumerator e = beta.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("20", e.Current[0].GetString()); } @@ -51,7 +50,7 @@ public void SameSheetCanBeEnumeratedMoreThanOnce() """12"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using (XlsxWorkbook.Enumerator first = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator first = reader.FirstSheet.GetEnumerator()) { Assert.True(first.MoveNext()); Assert.Equal("1", first.Current[0].GetString()); @@ -60,13 +59,13 @@ public void SameSheetCanBeEnumeratedMoreThanOnce() Assert.False(first.MoveNext()); } - using XlsxWorkbook.Enumerator second = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator second = reader.FirstSheet.GetEnumerator(); Assert.True(second.MoveNext()); Assert.Equal("1", second.Current[0].GetString()); } [Fact] - public void SwitchingAwayAndBackToASheetReenumeratesFromStart() + public void ReenumeratingASheetAfterReadingAnotherStartsFromTheFirstRow() { using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( [ @@ -75,21 +74,19 @@ public void SwitchingAwayAndBackToASheetReenumeratesFromStart() ]); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("11", e.Current[0].GetString()); } - reader.MoveToSheet(1); - using (XlsxWorkbook.Enumerator e = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator e = reader.Sheets[1].GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("22", e.Current[0].GetString()); } - reader.MoveToSheet(0); - using XlsxWorkbook.Enumerator back = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator back = reader.Sheets[0].GetEnumerator(); Assert.True(back.MoveNext()); Assert.Equal("11", back.Current[0].GetString()); } @@ -100,7 +97,7 @@ public void EmptySheetYieldsNoRows() using MemoryStream ms = WorkbookBuilder.Build(sheetRows: ""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.False(e.MoveNext()); } @@ -122,7 +119,7 @@ public void HugeSharedStringTableResolvesEveryIndexIncludingRepeatsViaCache() using MemoryStream ms = WorkbookBuilder.Build(rows.ToString(), sharedStrings: sharedStrings.ToString()); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); string? firstRepeat = null; for (int i = 0; i < 200; i++) @@ -142,7 +139,7 @@ public void NumericCellIsTypedAsNumber() { using MemoryStream ms = WorkbookBuilder.Build("""3.14"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); @@ -157,7 +154,7 @@ public void DateStyledNumericCellIsTypedAsDate() """45658""", styles: ""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Date, e.Current[0].Type); @@ -190,7 +187,7 @@ public void GetStringPreservesOriginalNumericTextRatherThanReformattingCachedDou using MemoryStream ms = WorkbookBuilder.Build( """1.50"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDouble(out double d)); @@ -209,7 +206,7 @@ public void TruncatedCellXfsIsNotFatal(string styles) styles: styles); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Number, e.Current[0].Type); } diff --git a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs index af1a6846..d60fb59b 100644 --- a/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs +++ b/tests/ExcelReader.Tests/Reader/Xlsx/XlsxXmlDialectTests.cs @@ -12,7 +12,7 @@ public void SingleQuotedCellAttributesAreRead() """0""", sharedStrings: "single quoted"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("single quoted", e.Current[2].GetString()); @@ -32,7 +32,7 @@ public void CellTagAttributeLayoutsResolveTheSameCell(string cells) $"""{cells}""", sharedStrings: "hit"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Cell cell = e.Current[27]; @@ -53,7 +53,7 @@ public void SharedStringIndexFormsResolveLikeTheGenericParser(string index, stri $"""{index}7""", sharedStrings: "firstsecond"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(expected, e.Current[0].GetString()); @@ -76,7 +76,7 @@ public void PlainAndMarkedUpSharedStringsInterleaveCorrectly() + "" + "last"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); for (int i = 0; i < expected.Length; i++) @@ -91,7 +91,7 @@ public void CommentContainingGreaterThanInsideSheetDataIsSkipped() using MemoryStream ms = WorkbookBuilder.Build( """12"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1", e.Current[0].GetString()); @@ -106,7 +106,7 @@ public async Task AsyncCommentContainingGreaterThanInsideSheetDataIsSkipped() await using MemoryStream ms = WorkbookBuilder.Build( """12"""); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - await using XlsxWorkbook.Enumerator e = reader.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetAsyncEnumerator(TestContext.Current.CancellationToken); Assert.True(await e.MoveNextAsync()); Assert.Equal("1", e.Current[0].GetString()); @@ -121,7 +121,7 @@ public void CDataInsideTextRunIsCopiedWithoutEntityDecoding() using MemoryStream ms = WorkbookBuilder.Build( """]]>"""); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("raw & ", e.Current[0].GetString()); @@ -134,7 +134,7 @@ public void CDataInsideSharedStringIsCopiedWithoutEntityDecoding() """0""", sharedStrings: "]]>"); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("shared & ", e.Current[0].GetString()); From d47fa32802216a477ae3a84802d41220fcada889 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 16:55:37 -0300 Subject: [PATCH 09/49] test(core): parse sheets in the parser tests Co-Authored-By: Claude Sonnet 5.5 --- .../Parser/ConverterTests.cs | 10 +-- .../Parser/CsvParserTests.cs | 44 +++++----- .../Parser/ExcelCellReadersUtf8Tests.cs | 2 +- .../Parser/ExcelParserTests.cs | 80 +++++++++---------- .../Parser/ExcelRowMapBuilderTests.cs | 18 ++--- .../Parser/FluentMappingTests.cs | 30 +++---- .../Parser/GeneratedMapParityTests.cs | 76 +++++++++--------- .../Parser/HeaderCollisionTests.cs | 2 +- .../ParallelCsv/AggregateCsvModelTests.cs | 4 +- .../ParallelCsv/AggregateCsvParallelTests.cs | 2 +- .../ParallelCsv/CsvHeaderBinderTests.cs | 4 +- .../CsvParallelDifferentialTests.cs | 2 +- .../ParallelCsv/ParallelCsvEnumerableTests.cs | 2 +- .../ParallelCsv/ParseCsvParallelTests.cs | 4 +- .../Parser/ParserFeatureTests.cs | 26 +++--- .../Parser/RefStructModelTests.cs | 26 +++--- .../ExcelReader.Tests/Parser/RequiredTests.cs | 24 +++--- .../Parser/UnifiedParserTests.cs | 18 ++--- 18 files changed, 187 insertions(+), 187 deletions(-) diff --git a/tests/ExcelReader.Tests/Parser/ConverterTests.cs b/tests/ExcelReader.Tests/Parser/ConverterTests.cs index 6a754aa1..efa44624 100644 --- a/tests/ExcelReader.Tests/Parser/ConverterTests.cs +++ b/tests/ExcelReader.Tests/Parser/ConverterTests.cs @@ -107,7 +107,7 @@ public async Task ConvertersParseDomainValues() ["R$ 1.234,56", "12.5%", "INV-1"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - InvoiceRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + InvoiceRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(1234.56m, row.Total); Assert.Equal(0.125, row.Tax.Fraction, precision: 10); @@ -122,7 +122,7 @@ public async Task ConverterFailureKeepsDefault() ["garbage", "nope", "INV-2"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - InvoiceRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + InvoiceRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(0m, row.Total); Assert.Equal(default, row.Tax); @@ -136,7 +136,7 @@ public async Task NullableConverterSetsAndLeavesNull() ("S1", [["Tax"], ["33%"], ["bad"], [null]])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - List rows = [.. ExcelParser.FromAttributes().Parse(reader)]; + List rows = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; Assert.Equal(3, rows.Count); Assert.Equal(0.33, rows[0].Tax!.Value.Fraction, precision: 10); @@ -156,7 +156,7 @@ public void ConverterReceivesDate1904Flag() using var reader = Excel.FromXlsx(ms); Assert.True(reader.IsDate1904); - DatedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + DatedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(1904, row.Year); } @@ -168,7 +168,7 @@ public async Task MismatchedConverterTypeThrows() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); InvalidOperationException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("IExcelCellConverter", ex.Message, StringComparison.Ordinal); } } diff --git a/tests/ExcelReader.Tests/Parser/CsvParserTests.cs b/tests/ExcelReader.Tests/Parser/CsvParserTests.cs index 052e890d..61f8a99b 100644 --- a/tests/ExcelReader.Tests/Parser/CsvParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/CsvParserTests.cs @@ -89,7 +89,7 @@ public void AllBasicTypesParseFromCsvText() using var ms = Csv("Name,Age,Score,Active,Balance\nAlice,42,95.5,true,12345.67\n"); using var reader = Excel.FromCsv(ms); - PersonRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + PersonRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal("Alice", row.Name); Assert.Equal(42, row.Age); @@ -104,7 +104,7 @@ public void ExcelColumnAliasMatchesHeader() using var ms = Csv("First Name\nBob\n"); using var reader = Excel.FromCsv(ms); - AliasRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + AliasRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal("Bob", row.FirstName); } @@ -115,7 +115,7 @@ public void HigherPriorityAliasReplacesEarlierLowerPriorityBinding() using var ms = Csv("Legacy Name,Preferred Name\nOld,New\n"); using var reader = Excel.FromCsv(ms); - MultiAliasRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + MultiAliasRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal("New", row.Name); } @@ -127,7 +127,7 @@ public void PtBrCultureParsesCommaDecimal() using var reader = Excel.FromCsv(ms); var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; - MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader).Single(); + MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); Assert.Equal(1234.56m, row.Amount); } @@ -139,7 +139,7 @@ public void EnumGuidAndNullableColumnsParseFromCsvText() using var ms = Csv($"Status,Id,Quantity\nActive,{id},7\n"); using var reader = Excel.FromCsv(ms); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Status.Active, row.Status); Assert.Equal(id, row.Id); @@ -153,7 +153,7 @@ public void EnumFromNumericTextBindsByValueInCsv() using var ms = Csv($"Status,Id,Quantity\n2,{id},7\n"); using var reader = Excel.FromCsv(ms); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Status.Closed, row.Status); } @@ -164,7 +164,7 @@ public void EmptyCellLeavesNullableColumnNull() using var ms = Csv("Status,Id,Quantity\nActive,,\n"); using var reader = Excel.FromCsv(ms); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Null(row.Quantity); Assert.Equal(Guid.Empty, row.Id); @@ -176,7 +176,7 @@ public void HeaderRowGreaterThanOneSkipsPrecedingRows() using var ms = Csv("ignore me\nName,Age,Score,Active,Balance\nAlice,42,95.5,true,12345.67\n"); var config = new ExcelParserConfig { HeaderRow = 2 }; - PersonRow row = ExcelParser.FromAttributes(config).Parse(Excel.FromCsv(ms)).Single(); + PersonRow row = ExcelParser.FromAttributes(config).Parse(Excel.FromCsv(ms).FirstSheet).Single(); Assert.Equal("Alice", row.Name); } @@ -187,7 +187,7 @@ public void MissingRequiredColumnThrowsAtHeader() using var ms = Csv("Note\nhi\n"); using var reader = Excel.FromCsv(ms); - Assert.Throws(() => ExcelParser.FromAttributes().Parse(reader).ToList()); + Assert.Throws(() => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); } [Fact] @@ -196,7 +196,7 @@ public void EmptyRequiredCellThrowsNamingColumnAndRow() using var ms = Csv("Id,Note\n,hi\n"); using var reader = Excel.FromCsv(ms); - Assert.Throws(() => ExcelParser.FromAttributes().Parse(reader).ToList()); + Assert.Throws(() => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); } [Fact] @@ -206,7 +206,7 @@ public void RequiredCellWithUnparseableValueThrowsAsIfMissing() using var reader = Excel.FromCsv(ms); ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("Id", ex.Message, StringComparison.Ordinal); } @@ -218,7 +218,7 @@ public void ThrowOnParseFailureThrowsExcelParseExceptionForUnparseableColumn() var config = new ExcelParserConfig { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes(config).Parse(reader).ToList()); + () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); Assert.Equal("Amount", ex.ColumnName); Assert.Equal("not-a-number", ex.RawValue); } @@ -249,7 +249,7 @@ public void TextDatesBindExactlyAsTheCultureParserWould(string text) using var ms = Csv($"At,Day\n{text},{text[..Math.Min(10, text.Length)]}\n"); using var reader = Excel.FromCsv(ms); - StampRow row = Assert.Single(ExcelParser.FromAttributes().Parse(reader).ToList()); + StampRow row = Assert.Single(ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Equal(DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.None), row.At); Assert.Equal(DateOnly.Parse(text[..Math.Min(10, text.Length)], CultureInfo.InvariantCulture, DateTimeStyles.None), row.Day); @@ -266,7 +266,7 @@ public void ANonFiniteTimeOnlyCellFailsToParseInsteadOfBecomingMidnight(string t var config = new ExcelParserConfig { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes(config).Parse(reader).ToList()); + () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); Assert.Equal("Start", ex.ColumnName); } @@ -276,7 +276,7 @@ public void TerminalBlankLineDoesNotYieldPhantomModelOrRequiredFailure() using var ms = Csv("Id,Note\n7,valid\n\n"); using var reader = Excel.FromCsv(ms); - RequiredRow row = Assert.Single(ExcelParser.FromAttributes().Parse(reader).ToList()); + RequiredRow row = Assert.Single(ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Equal(7, row.Id); Assert.Equal("valid", row.Note); @@ -288,7 +288,7 @@ public void PlainDateTimeAndDateOnlyColumnsParseTextNatively() using var ms = Csv("Created,Day\n2026-07-02T08:30:00,2026-07-02\n"); using var reader = Excel.FromCsv(ms); - DateRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + DateRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(new DateTime(2026, 7, 2, 8, 30, 0, DateTimeKind.Unspecified), row.Created); Assert.Equal(new DateOnly(2026, 7, 2), row.Day); @@ -300,7 +300,7 @@ public void UnparseableDateColumnKeepsDefault() using var ms = Csv("Created,Day\nnot-a-date,\n"); using var reader = Excel.FromCsv(ms); - DateRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + DateRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(default, row.Created); Assert.Null(row.Day); @@ -313,7 +313,7 @@ public void DateColumnRespectsCulture() using var reader = Excel.FromCsv(ms); var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; - DateRow row = ExcelParser.FromAttributes(config).Parse(reader).Single(); + DateRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); Assert.Equal(new DateTime(2026, 7, 2, 0, 0, 0, DateTimeKind.Unspecified), row.Created); Assert.Equal(new DateOnly(2026, 7, 2), row.Day); @@ -325,7 +325,7 @@ public void CustomConverterStillOverridesDateParsing() using var ms = Csv("Created\n2026-07-02\n"); using var reader = Excel.FromCsv(ms); - ConvertedDateRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + ConvertedDateRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(new DateTime(2026, 7, 2, 0, 0, 0, DateTimeKind.Unspecified), row.Created); } @@ -337,7 +337,7 @@ public async Task ParseAsyncReadsAllRows() using var reader = Excel.FromCsv(ms); var rows = new List(); - await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (PersonRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { rows.Add(row); } @@ -352,7 +352,7 @@ public void NonGenericEnumerableGetEnumeratorWorks() { using var ms = Csv("Name\nAlice\n"); using var reader = Excel.FromCsv(ms); - IEnumerable enumerable = ExcelParser.FromAttributes().Parse(reader); + IEnumerable enumerable = ExcelParser.FromAttributes().Parse(reader.FirstSheet); IEnumerator e = enumerable.GetEnumerator(); @@ -365,7 +365,7 @@ public void EnumeratorResetThrows() { using var ms = Csv("Name\nAlice\n"); using var reader = Excel.FromCsv(ms); - using var e = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + using var e = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.Throws(e.Reset); } diff --git a/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs b/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs index c37aff25..6680c1b7 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelCellReadersUtf8Tests.cs @@ -13,7 +13,7 @@ public void ReadsTheRawBytesOfEveryField() byte[] csv = "plain,\"quoted, \"\"text\"\"\",\n"u8.ToArray(); using CsvReader reader = Excel.FromCsv(csv); var seen = new List(); - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { for (int i = 0; i < 3; i++) { diff --git a/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs b/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs index a01770e8..6ad2e6b5 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelParserTests.cs @@ -67,7 +67,7 @@ public async Task StringPropertyIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); } @@ -77,7 +77,7 @@ public async Task IntPropertyIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Age"], [42]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(42, result[0].Age); } @@ -87,7 +87,7 @@ public async Task DoublePropertyIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Score"], [95.5]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(95.5, result[0].Score); } @@ -97,7 +97,7 @@ public async Task DecimalPropertyIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Balance"], [12345.67m]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(12345.67m, result[0].Balance); } @@ -107,7 +107,7 @@ public async Task BoolPropertyTrueIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Active"], [true]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.True(result[0].Active); } @@ -117,7 +117,7 @@ public async Task BoolPropertyFalseIsMapped() { await using var ms = await TypedWorkbook.BuildAsync(["Active"], [false]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.False(result[0].Active); } @@ -129,7 +129,7 @@ public async Task AllBasicTypesAreMappedTogether() ["Name", "Age", "Score", "Active", "Balance"], ["Bob", 35, 88.25, true, 500.00m]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Bob", result[0].Name); Assert.Equal(35, result[0].Age); @@ -146,7 +146,7 @@ public async Task ExcelColumnAttributeOverridesPropertyName() ["First Name", "Last Name", "Count"], ["John", "Doe", 5]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("John", result[0].FirstName); Assert.Equal("Doe", result[0].LastName); @@ -158,7 +158,7 @@ public async Task HeaderMatchingPropertyNameNotAttributeNameIsIgnored() { await using var ms = await TypedWorkbook.BuildAsync(["FirstName"], ["Jane"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].FirstName); } @@ -168,7 +168,7 @@ public async Task PropertyWithoutAttributeMatchesByName() { await using var ms = await TypedWorkbook.BuildAsync(["Count"], [7]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(7, result[0].Count); } @@ -178,7 +178,7 @@ public async Task MultipleExcelColumnAttributesCanMatchFallbackAlias() { await using var ms = await TypedWorkbook.BuildAsync(["Legacy Name"], ["Jane"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Jane", result[0].Name); } @@ -190,7 +190,7 @@ public async Task MultipleExcelColumnAttributesPreferFirstMatchingAttribute() ["Legacy Name", "Preferred Name"], ["Old", "New"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("New", result[0].Name); } @@ -202,7 +202,7 @@ public async Task MultipleExcelColumnAttributesIgnoreLowerPriorityAliasAfterPref ["Preferred Name", "Legacy Name"], ["New", "Old"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("New", result[0].Name); } @@ -213,7 +213,7 @@ public async Task DefaultComparerIsOrdinalIgnoreCase() { await using var ms = await TypedWorkbook.BuildAsync(["name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); } @@ -224,7 +224,7 @@ public async Task OrdinalComparerRejectsCaseMismatch() await using var ms = await TypedWorkbook.BuildAsync(["name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var config = new ExcelParserConfig { ColumnNameComparer = StringComparer.Ordinal }; - var result = ExcelParser.FromAttributes(config).Parse(reader).ToList(); + var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].Name); } @@ -235,7 +235,7 @@ public async Task OrdinalComparerAcceptsExactCaseMatch() await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var config = new ExcelParserConfig { ColumnNameComparer = StringComparer.Ordinal }; - var result = ExcelParser.FromAttributes(config).Parse(reader).ToList(); + var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); } @@ -250,7 +250,7 @@ public async Task HeaderRowTwoSkipsFirstRow() ["Alice"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var config = new ExcelParserConfig { HeaderRow = 2 }; - var result = ExcelParser.FromAttributes(config).Parse(reader).ToList(); + var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); } @@ -265,7 +265,7 @@ public async Task HeaderRowThreeSkipsTwoRows() ["Bob"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var config = new ExcelParserConfig { HeaderRow = 3 }; - var result = ExcelParser.FromAttributes(config).Parse(reader).ToList(); + var result = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Bob", result[0].Name); } @@ -293,7 +293,7 @@ public async Task ExtraColumnsAreIgnored() ["Name", "UnknownColumn"], ["Carol", "extra"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Carol", result[0].Name); } @@ -303,7 +303,7 @@ public async Task MissingColumnsKeepDefault() { await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["Dave"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Dave", result[0].Name); Assert.Equal(0, result[0].Age); @@ -319,7 +319,7 @@ public async Task ColumnOrderIsIrrelevant() ["Age", "Name"], [25, "Eve"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Eve", result[0].Name); Assert.Equal(25, result[0].Age); @@ -338,7 +338,7 @@ public async Task MultipleRowsHaveNoStateBleed() } await using var ms = await TypedWorkbook.BuildAsync(rows); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(count, result.Count); for (int i = 0; i < count; i++) { @@ -352,7 +352,7 @@ public async Task EmptySheetYieldsNoRows() { await using var ms = await TypedWorkbook.BuildAsync(); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Empty(result); } @@ -361,7 +361,7 @@ public async Task HeaderOnlyYieldsNoRows() { await using var ms = await TypedWorkbook.BuildAsync(["Name"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Empty(result); } @@ -371,7 +371,7 @@ public async Task NullableIntFilledCellYieldsValue() { await using var ms = await TypedWorkbook.BuildAsync(["Quantity"], [99]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(99, result[0].Quantity); } @@ -383,7 +383,7 @@ public async Task NullableIntMissingCellYieldsNull() ["Quantity", "Rate"], [new Gap(), 1.5]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].Quantity); Assert.Equal(1.5, result[0].Rate); @@ -394,7 +394,7 @@ public async Task NullableDoubleFilledCellYieldsValue() { await using var ms = await TypedWorkbook.BuildAsync(["Rate"], [3.14]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(3.14, result[0].Rate); } @@ -404,7 +404,7 @@ public async Task NullableDateTimeFilledCellYieldsValue() { await using var ms = await TypedWorkbook.BuildAsync(["EventDate"], [Jan1970]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(Jan1970, result[0].EventDate); } @@ -416,7 +416,7 @@ public async Task NullableDateTimeMissingCellYieldsNull() ["EventDate", "Rate"], [new Gap(), 5.0]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Null(result[0].EventDate); } @@ -430,7 +430,7 @@ public async Task StructRowIsSupported() [1.5, 2.5, "Point"], [3.0, 4.0, "Vector"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, result.Count); Assert.Equal(1.5, result[0].X); Assert.Equal(2.5, result[0].Y); @@ -448,7 +448,7 @@ public async Task TwoStructRowsAreDistinct() [10.0], [20.0]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, result.Count); Assert.Equal(10.0, result[0].X); Assert.Equal(20.0, result[1].X); @@ -459,7 +459,7 @@ public async Task StructWithExplicitParameterlessConstructorRunsItsInitializer() { await using var ms = await TypedWorkbook.BuildAsync(["X"], [1.5]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(1.5, result[0].X); Assert.Equal("ctor-default", result[0].Tag); @@ -471,7 +471,7 @@ public async Task DateCellWithStandardDateStyleIsConverted() { await using var ms = await TypedWorkbook.BuildAsync(["BirthDate"], [Jan1970]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(Jan1970, result[0].BirthDate); } @@ -486,7 +486,7 @@ public void Date1904IsHandledCorrectly() date1904: true); using var reader = Excel.FromXlsx(ms); Assert.True(reader.IsDate1904); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(new DateTime(1904, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), result[0].BirthDate); } @@ -497,7 +497,7 @@ public async Task NonNumericValueInIntColumnKeepsDefault() { await using var ms = await TypedWorkbook.BuildAsync(["Age"], ["not-a-number"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal(0, result[0].Age); } @@ -507,7 +507,7 @@ public async Task ParseFailureDoesNotThrowException() { await using var ms = await TypedWorkbook.BuildAsync(["Age"], ["N/A"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var ex = Record.Exception(() => ExcelParser.FromAttributes().Parse(reader).ToList()); + var ex = Record.Exception(() => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Null(ex); } @@ -525,13 +525,13 @@ public async Task AsyncParseMatchesSyncParse() await using (var reader = Excel.FromXlsx(ms)) { - syncResult = [.. ExcelParser.FromAttributes().Parse(reader)]; + syncResult = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; } ms.Position = 0; await using (var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken)) { - await foreach (var row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (var row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { asyncResult.Add(row); } @@ -558,7 +558,7 @@ public async Task AsyncParseYieldsAllRows() await using var ms = await TypedWorkbook.BuildAsync(rows); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var result = new List(); - await foreach (var row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (var row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { result.Add(row); } @@ -578,7 +578,7 @@ public void SharedStringHeaderIsResolved() """Alice""", sharedStrings: "Name"); using var reader = Excel.FromXlsx(ms); - var result = ExcelParser.FromAttributes().Parse(reader).ToList(); + var result = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(result); Assert.Equal("Alice", result[0].Name); } diff --git a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs index 3d7226c2..87c5fea2 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs @@ -71,11 +71,11 @@ public async Task MappedParserMatchesReflectionOnRead() ["Alice", 30, SampleBirthDate, true, "Beta"]); await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken); - List mapped = ExcelParser.Generated().Parse(mappedReader).ToList(); + List mapped = ExcelParser.Generated().Parse(mappedReader.FirstSheet).ToList(); ms.Position = 0; await using XlsxWorkbook reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - List reflected = ExcelParser.FromAttributes().Parse(reflectionReader).ToList(); + List reflected = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).ToList(); Assert.Single(mapped); Assert.Single(reflected); @@ -103,7 +103,7 @@ public async Task RequiredColumnMissingThrowsLikeReflection() await using MemoryStream reflectedStream = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); ExcelParseException reflectedEx = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reflectedReader).ToList()); + () => ExcelParser.FromAttributes().Parse(reflectedReader.FirstSheet).ToList()); Assert.Equal(reflectedEx.Message, mappedEx.Message); } @@ -146,8 +146,8 @@ public async Task RecordMapBuilderMatchesReflectionOnWrite() reflectedStream.Position = 0; await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(reflectedStream, ct: TestContext.Current.CancellationToken); - List mapped = ExcelParser.FromAttributes().Parse(mappedReader).ToList(); - List reflected = ExcelParser.FromAttributes().Parse(reflectedReader).ToList(); + List mapped = ExcelParser.FromAttributes().Parse(mappedReader.FirstSheet).ToList(); + List reflected = ExcelParser.FromAttributes().Parse(reflectedReader.FirstSheet).ToList(); Assert.Single(mapped); Assert.Single(reflected); @@ -192,7 +192,7 @@ public async Task GeneratedLayoutRoundTripsThroughXlsx() stream.Position = 0; await using XlsxWorkbook reader = await Excel.FromXlsxAsync(stream, ct: ct); - List results = ExcelParser.Generated().Parse(reader).ToList(); + List results = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); Assert.Single(results); AssertMatches(record, results[0]); @@ -215,7 +215,7 @@ public async Task GeneratedLayoutRoundTripsThroughXlsb() stream.Position = 0; await using XlsbWorkbook reader = await Excel.FromXlsbAsync(stream, leaveOpen: false, ct: ct); - List results = ExcelParser.Generated().Parse(reader).ToList(); + List results = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); Assert.Single(results); AssertMatches(record, results[0]); @@ -238,7 +238,7 @@ public async Task GeneratedLayoutRoundTripsThroughXls() stream.Position = 0; await using XlsWorkbook reader = Excel.FromXls(stream, leaveOpen: false); - List results = ExcelParser.Generated().Parse(reader).ToList(); + List results = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); Assert.Single(results); AssertMatches(record, results[0]); @@ -261,7 +261,7 @@ public async Task GeneratedLayoutRoundTripsThroughCsv() stream.Position = 0; await using CsvReader reader = Excel.FromCsv(stream, leaveOpen: false); - List results = ExcelParser.Generated().Parse(reader).ToList(); + List results = ExcelParser.Generated().Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal(record.Name, results[0].Name); diff --git a/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs b/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs index ce496916..0e58ac7e 100644 --- a/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs +++ b/tests/ExcelReader.Tests/Parser/FluentMappingTests.cs @@ -43,7 +43,7 @@ public async Task TwoDifferentMapsForSameModelInOneProcess() .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); await using XlsxWorkbook readerA = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); - List resultA = parserA.Parse(readerA).ToList(); + List resultA = parserA.Parse(readerA.FirstSheet).ToList(); ms.Position = 0; var parserB = ExcelParser.Build(static b => b @@ -51,7 +51,7 @@ public async Task TwoDifferentMapsForSameModelInOneProcess() .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant()) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v * 2)); await using XlsxWorkbook readerB = await Excel.FromXlsxAsync(ms, ct: ct); - List resultB = parserB.Parse(readerB).ToList(); + List resultB = parserB.Parse(readerB.FirstSheet).ToList(); Assert.Equal("Alice", resultA[0].Name); Assert.Equal(30, resultA[0].Age); @@ -70,7 +70,7 @@ public async Task ModelWithNoAttributesIsFullyMappable() .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal("Bob", results[0].Name); @@ -88,7 +88,7 @@ public async Task MapByColumnIndexWithoutHeaderRow() .PropertyAt(0, ExcelCellReaders.String, static (ref m, v) => m.Name = v) .PropertyAt(1, ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v)); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal("Carol", results[0].Name); @@ -107,7 +107,7 @@ public async Task RequiredColumnMissingByIndexThrows() .PropertyAt(1, ExcelCellReaders.Parsable, static (ref SharedModel m, int v) => m.Age = v, requireValue: true)); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - Assert.Throws(() => parser.Parse(reader).ToList()); + Assert.Throws(() => parser.Parse(reader.FirstSheet).ToList()); } [Fact] @@ -119,7 +119,7 @@ public async Task FluentOverridesAttributeForConfiguredProperty() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant())); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal("ERIN", results[0].Name); @@ -134,7 +134,7 @@ public async Task AttributeSurvivesForPropertyNotInBuilder() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v.ToUpperInvariant())); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal(19, results[0].Age); @@ -151,11 +151,11 @@ public async Task FluentMapMatchesAttributeMapForEquivalentConfig() .Property(["Name"], ExcelCellReaders.String, static (ref m, v) => m.Name = v) .Property(["Age"], ExcelCellReaders.Parsable, static (ref AttributedModel m, int v) => m.Age = v)); await using XlsxWorkbook fluentReader = await Excel.FromXlsxAsync(ms, leaveOpen: true, ct: ct); - List fluentResults = fluentParser.Parse(fluentReader).ToList(); + List fluentResults = fluentParser.Parse(fluentReader.FirstSheet).ToList(); ms.Position = 0; await using XlsxWorkbook reflectedReader = await Excel.FromXlsxAsync(ms, ct: ct); - List reflectedResults = ExcelParser.FromAttributes().Parse(reflectedReader).ToList(); + List reflectedResults = ExcelParser.FromAttributes().Parse(reflectedReader.FirstSheet).ToList(); Assert.Single(fluentResults); Assert.Single(reflectedResults); @@ -183,7 +183,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } xlsxStream.Position = 0; await using XlsxWorkbook xlsxReader = await Excel.FromXlsxAsync(xlsxStream, ct: ct); - AssertMatches(record, ExcelParser.Build(configure).Parse(xlsxReader).Single()); + AssertMatches(record, ExcelParser.Build(configure).Parse(xlsxReader.FirstSheet).Single()); await using var xlsbStream = new MemoryStream(); await using (XlsbWorkbookWriter writer = XlsbWorkbookWriter.Create(xlsbStream, leaveOpen: true)) @@ -195,7 +195,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } xlsbStream.Position = 0; await using XlsbWorkbook xlsbReader = await Excel.FromXlsbAsync(xlsbStream, leaveOpen: false, ct: ct); - AssertMatches(record, ExcelParser.Build(configure).Parse(xlsbReader).Single()); + AssertMatches(record, ExcelParser.Build(configure).Parse(xlsbReader.FirstSheet).Single()); await using var xlsStream = new MemoryStream(); await using (XlsWorkbookWriter writer = XlsWorkbookWriter.Create(xlsStream, leaveOpen: true)) @@ -207,7 +207,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } xlsStream.Position = 0; using XlsWorkbook xlsReader = Excel.FromXls(xlsStream, leaveOpen: false); - AssertMatches(record, ExcelParser.Build(configure).Parse(xlsReader).Single()); + AssertMatches(record, ExcelParser.Build(configure).Parse(xlsReader.FirstSheet).Single()); await using var csvStream = new MemoryStream(); await using (CsvWorkbookWriter writer = CsvWorkbookWriter.Create(csvStream, leaveOpen: true)) @@ -219,7 +219,7 @@ public async Task FluentMapWorksAcrossAllFourFormats() } csvStream.Position = 0; using CsvReader csvReader = Excel.FromCsv(csvStream, leaveOpen: false); - AssertMatches(record, ExcelParser.Build(configure).Parse(csvReader).Single()); + AssertMatches(record, ExcelParser.Build(configure).Parse(csvReader.FirstSheet).Single()); } [Fact] @@ -231,7 +231,7 @@ public async Task FluentBindingWithDifferentHeaderDoesNotOverrideAttribute() ExcelParser parser = ExcelParser.BuildWithAttributeFallback(static b => b .Property(["arquivo"], ExcelCellReaders.String, static (ref m, v) => m.Name = v)); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal("FromFluent", results[0].Name); @@ -266,7 +266,7 @@ public async Task PlainConstructorAllowsValueTypeModelWithNoFactory() var parser = ExcelParser.Build(static b => b .Property(["Age"], ExcelCellReaders.Parsable, static (ref StructModel m, int v) => m.Age = v)); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: ct); - List results = parser.Parse(reader).ToList(); + List results = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(results); Assert.Equal(42, results[0].Age); diff --git a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs index a4120778..f9bd977a 100644 --- a/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs +++ b/tests/ExcelReader.Tests/Parser/GeneratedMapParityTests.cs @@ -215,8 +215,8 @@ public async Task GeneratedMapMatchesReflectionForEverySetterShape() ["BaseInit", "Settable", "InitOnly", "NullableInit", "ConvertedInit", "GetOnly", "PrivateSet", "Hidden"], ["base", "set", "init", 7, "conv", "x", "y", "z"]); - SetterShapesModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - SetterShapesModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + SetterShapesModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + SetterShapesModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); foreach (SetterShapesModel result in new[] { reflectionResult, generatedResult }) { @@ -236,10 +236,10 @@ public async Task GeneratedMapMatchesReflectionForInitOnlyStructAndRecord() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Age"], ["Alice", 30]); - InitStructModel reflectionStruct = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - InitStructModel generatedStruct = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); - InitRecordModel reflectionRecord = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - InitRecordModel generatedRecord = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + InitStructModel reflectionStruct = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + InitStructModel generatedStruct = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); + InitRecordModel reflectionRecord = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + InitRecordModel generatedRecord = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal(new InitStructModel { Name = "Alice", Age = 30 }, reflectionStruct); Assert.Equal(reflectionStruct, generatedStruct); @@ -253,14 +253,14 @@ public async Task GeneratedMapMatchesReflectionForInitOnlyRefStruct() await using var ms = await TypedWorkbook.BuildAsync(["Name", "Age"], ["Alice", 30]); using var reflectionReader = Excel.FromXlsx(ms, leaveOpen: true); - var reflectionEnum = ExcelParser.FromAttributes().Parse(reflectionReader).GetEnumerator(); + var reflectionEnum = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); InitRefStructModel reflectionRow = reflectionEnum.Current; (string Name, int Age) reflectionResult = (System.Text.Encoding.UTF8.GetString(reflectionRow.Name), reflectionRow.Age); ms.Position = 0; using var generatedReader = Excel.FromXlsx(ms, leaveOpen: true); - var generatedEnum = ExcelParser.Generated().Parse(generatedReader).GetEnumerator(); + var generatedEnum = ExcelParser.Generated().Parse(generatedReader.FirstSheet).GetEnumerator(); Assert.True(generatedEnum.MoveNext()); InitRefStructModel generatedRow = generatedEnum.Current; (string Name, int Age) generatedResult = (System.Text.Encoding.UTF8.GetString(generatedRow.Name), generatedRow.Age); @@ -320,8 +320,8 @@ public async Task GeneratedMapMatchesReflectionForEveryBuiltInType() ]; await using var ms = await TypedWorkbook.BuildAsync(header, row); - EveryTypeModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - EveryTypeModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + EveryTypeModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + EveryTypeModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal(reflectionResult.Name, generatedResult.Name); Assert.Equal(reflectionResult.Active, generatedResult.Active); @@ -357,8 +357,8 @@ public async Task GeneratedMapMatchesReflectionForCustomConverter() { await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["alice"]); - ConverterModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - ConverterModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + ConverterModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + ConverterModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal("ALICE", reflectionResult.Name); Assert.Equal("ALICE", generatedResult.Name); @@ -369,8 +369,8 @@ public async Task GeneratedMapHonorsColumnAliases() { await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); - AliasModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - AliasModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + AliasModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + AliasModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal("Alice", reflectionResult.Name); Assert.Equal("Alice", generatedResult.Name); @@ -381,8 +381,8 @@ public async Task GeneratedMapHonorsIgnore() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Ignored"], ["Alice", "SHOULD_NOT_BIND"]); - IgnoreModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - IgnoreModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + IgnoreModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + IgnoreModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal("Alice", reflectionResult.Name); Assert.Equal("default", reflectionResult.Ignored); @@ -395,8 +395,8 @@ public async Task GeneratedMapMatchesReflectionForInheritedProperties() { await using var ms = await TypedWorkbook.BuildAsync(["Inherited", "Own"], ["BaseValue", 42]); - InheritedModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader)); - InheritedModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader)); + InheritedModel reflectionResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet)); + InheritedModel generatedResult = await ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet)); Assert.Equal("BaseValue", reflectionResult.Inherited); Assert.Equal(42, reflectionResult.Own); @@ -410,9 +410,9 @@ public async Task GeneratedMapThrowsOnMissingRequiredHeader() await using var ms = await TypedWorkbook.BuildAsync(["Other"], ["x"]); ExcelParseException reflectionException = await Assert.ThrowsAsync( - () => ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader))); + () => ParseFirstXlsxAsync(ms, static reader => ExcelParser.FromAttributes().Parse(reader.FirstSheet))); ExcelParseException generatedException = await Assert.ThrowsAsync( - () => ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader))); + () => ParseFirstXlsxAsync(ms, static reader => ExcelParser.Generated().Parse(reader.FirstSheet))); Assert.Contains("Name", reflectionException.ColumnName, StringComparison.Ordinal); Assert.Contains("Name", generatedException.ColumnName, StringComparison.Ordinal); @@ -438,10 +438,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXlsx() } ms.Position = 0; await using XlsxWorkbook reflectionReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); + CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).First(); ms.Position = 0; await using XlsxWorkbook generatedReader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); + CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader.FirstSheet).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); } @@ -466,10 +466,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXlsb() } ms.Position = 0; using XlsbWorkbook reflectionReader = Excel.FromXlsb(ms); - CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); + CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).First(); ms.Position = 0; using XlsbWorkbook generatedReader = Excel.FromXlsb(ms); - CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); + CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader.FirstSheet).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); } @@ -494,10 +494,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsXls() } ms.Position = 0; using XlsWorkbook reflectionReader = Excel.FromXls(ms); - CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); + CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).First(); ms.Position = 0; using XlsWorkbook generatedReader = Excel.FromXls(ms); - CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); + CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader.FirstSheet).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); } @@ -521,10 +521,10 @@ public async Task GeneratedMapParityAcrossAllFourFormatsCsv() ms.Position = 0; CsvReader reflectionReader = Excel.FromCsv(ms); - CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader).First(); + CrossFormatModel reflectionResult = ExcelParser.FromAttributes().Parse(reflectionReader.FirstSheet).First(); ms.Position = 0; CsvReader generatedReader = Excel.FromCsv(ms); - CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader).First(); + CrossFormatModel generatedResult = ExcelParser.Generated().Parse(generatedReader.FirstSheet).First(); AssertCrossFormatEqual(reflectionResult, generatedResult); } @@ -633,7 +633,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsx() reflectionMs.Position = 0; using XlsxWorkbook reflectionReader = Excel.FromXlsx(reflectionMs); - using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.FirstSheet.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); @@ -641,7 +641,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsx() generatedMs.Position = 0; using XlsxWorkbook generatedReader = Excel.FromXlsx(generatedMs); - using XlsxWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsxWorkbook.Enumerator generatedEnum = generatedReader.FirstSheet.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -676,7 +676,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsb() reflectionMs.Position = 0; using XlsbWorkbook reflectionReader = Excel.FromXlsb(reflectionMs); - using XlsbWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsbWorkbook.Enumerator reflectionEnum = reflectionReader.FirstSheet.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); @@ -684,7 +684,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXlsb() generatedMs.Position = 0; using XlsbWorkbook generatedReader = Excel.FromXlsb(generatedMs); - using XlsbWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsbWorkbook.Enumerator generatedEnum = generatedReader.FirstSheet.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -719,7 +719,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXls() reflectionMs.Position = 0; using XlsWorkbook reflectionReader = Excel.FromXls(reflectionMs); - using XlsWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsWorkbook.Enumerator reflectionEnum = reflectionReader.FirstSheet.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); @@ -727,7 +727,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteXls() generatedMs.Position = 0; using XlsWorkbook generatedReader = Excel.FromXls(generatedMs); - using XlsWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsWorkbook.Enumerator generatedEnum = generatedReader.FirstSheet.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -762,7 +762,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteCsv() reflectionMs.Position = 0; using CsvReader reflectionReader = Excel.FromCsv(reflectionMs); - using CsvReader.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using CsvReader.Enumerator reflectionEnum = reflectionReader.FirstSheet.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); @@ -770,7 +770,7 @@ public async Task GeneratedRecordMapMatchesReflectionOnWriteCsv() generatedMs.Position = 0; using CsvReader generatedReader = Excel.FromCsv(generatedMs); - using CsvReader.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using CsvReader.Enumerator generatedEnum = generatedReader.FirstSheet.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); @@ -805,7 +805,7 @@ public async Task GeneratedRecordMapHeadersMatchReflectionHeaders() reflectionMs.Position = 0; using XlsxWorkbook reflectionReader = Excel.FromXlsx(reflectionMs); - using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.GetEnumerator(); + using XlsxWorkbook.Enumerator reflectionEnum = reflectionReader.FirstSheet.GetEnumerator(); Assert.True(reflectionEnum.MoveNext()); string[] reflectionHeaders = ReadRowText(reflectionEnum.Current); Assert.True(reflectionEnum.MoveNext()); @@ -813,7 +813,7 @@ public async Task GeneratedRecordMapHeadersMatchReflectionHeaders() generatedMs.Position = 0; using XlsxWorkbook generatedReader = Excel.FromXlsx(generatedMs); - using XlsxWorkbook.Enumerator generatedEnum = generatedReader.GetEnumerator(); + using XlsxWorkbook.Enumerator generatedEnum = generatedReader.FirstSheet.GetEnumerator(); Assert.True(generatedEnum.MoveNext()); string[] generatedHeaders = ReadRowText(generatedEnum.Current); Assert.True(generatedEnum.MoveNext()); diff --git a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs index bf2fee70..54b1c486 100644 --- a/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs +++ b/tests/ExcelReader.Tests/Parser/HeaderCollisionTests.cs @@ -85,7 +85,7 @@ private static async Task> ParseAsync(ExcelParser parser, object?[ { await using MemoryStream ms = await TypedWorkbook.BuildAsync(header, row); await using XlsxWorkbook reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - return [.. parser.Parse(reader)]; + return [.. parser.Parse(reader.FirstSheet)]; } [Fact] diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs index 716043ea..b8f33ed3 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvModelTests.cs @@ -76,7 +76,7 @@ private static List Sequential(byte[] csv, int headerRow) { using CsvReader reader = Excel.FromCsv(csv); var rows = new List(); - foreach (Person person in ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = headerRow }).Parse(reader)) + foreach (Person person in ExcelParser.FromAttributes(new ExcelParserConfig { HeaderRow = headerRow }).Parse(reader.FirstSheet)) { rows.Add(Render(person)); } @@ -327,7 +327,7 @@ private static void WritePerson(ExcelRecordMapBuilder bui private static List ReadPeople(XlsxWorkbook reader) { var rows = new List(); - foreach (Person person in ExcelParser.FromAttributes().Parse(reader)) + foreach (Person person in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { rows.Add(Render(person)); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs index 4a9a0bf2..262e54d6 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/AggregateCsvParallelTests.cs @@ -82,7 +82,7 @@ private static List Sequential(byte[] csv, int headerRow = 0) { using CsvReader reader = Excel.FromCsv(csv); var rows = new List(); - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { rows.Add(Render(row)); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs index 870d2a8c..cd1495ff 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvHeaderBinderTests.cs @@ -47,7 +47,7 @@ public void AProjectorBuiltFromABoundMapParsesTheVeryFirstRowAsData() out long dataStart); using var dataReader = Excel.FromCsv(all.AsMemory((int)dataStart)); - using CsvReader.Enumerator rows = dataReader.GetEnumerator(); + using CsvReader.Enumerator rows = dataReader.FirstSheet.GetEnumerator(); var projector = new CsvRowProjector( TypeMapper.GetCsvInfo(), map, @@ -85,7 +85,7 @@ public void TheSameBoundMapIsSafeToShareAcrossConcurrentProjectors() Parallel.For(0, 8, i => { using var dataReader = Excel.FromCsv(all.AsMemory((int)dataStart)); - using CsvReader.Enumerator rows = dataReader.GetEnumerator(); + using CsvReader.Enumerator rows = dataReader.FirstSheet.GetEnumerator(); var projector = new CsvRowProjector( TypeMapper.GetCsvInfo(), map, CultureInfo.InvariantCulture, throwOnParseFailure: false); var local = new List(); diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvParallelDifferentialTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvParallelDifferentialTests.cs index baabaf70..d61e60ac 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvParallelDifferentialTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/CsvParallelDifferentialTests.cs @@ -19,7 +19,7 @@ private static List Sequential(byte[] csv) { using var reader = Excel.FromCsv(csv); var list = new List(); - foreach (Row row in ExcelParser.FromAttributes().Parse(reader)) + foreach (Row row in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { list.Add(Render(row)); } diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs index 051ae426..cc18ecb0 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParallelCsvEnumerableTests.cs @@ -46,7 +46,7 @@ private static async Task AssertSameFailureAsSequentialAsync(byte[] csv, E { using CsvReader sequentialReader = Excel.FromCsv(csv); ExcelParseException expected = Assert.Throws( - () => ExcelParser.FromAttributes(config).Parse(sequentialReader).ToList()); + () => ExcelParser.FromAttributes(config).Parse(sequentialReader.FirstSheet).ToList()); ExcelParseException actual = await Assert.ThrowsAsync(async () => { diff --git a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs index a938280e..7cda722e 100644 --- a/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParallelCsv/ParseCsvParallelTests.cs @@ -39,7 +39,7 @@ public async Task MemoryOverloadMatchesTheSequentialParser() { byte[] csv = BuildCsv(20_000); using var reader = Excel.FromCsv(csv); - List expected = [.. ExcelParser.FromAttributes().Parse(reader)]; + List expected = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; List actual = await DrainAsync(CsvParallel.ParseAsync(csv.AsMemory(), ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 4 }, ct: TestContext.Current.CancellationToken)); @@ -60,7 +60,7 @@ public async Task FileOverloadMatchesTheSequentialParser() try { using var reader = Excel.FromCsvFile(path); - List expected = [.. ExcelParser.FromAttributes().Parse(reader)]; + List expected = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; List actual = await DrainAsync(CsvParallel.ParseAsync(path, ExcelParser.FromAttributes(), options: new CsvParallelOptions { DegreeOfParallelism = 8 }, ct: TestContext.Current.CancellationToken)); diff --git a/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs b/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs index fc61e95f..a29a87dc 100644 --- a/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs +++ b/tests/ExcelReader.Tests/Parser/ParserFeatureTests.cs @@ -42,9 +42,9 @@ private sealed class TypedRow public async Task ParseAcceptsAutoDetectedReader() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Alice", 12.5]); - using IExcelRowReader reader = Excel.Open(ms); + using IExcelWorkbook reader = Excel.Open(ms); - MoneyRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + MoneyRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal("Alice", row.Name); Assert.Equal(12.5m, row.Amount); @@ -54,10 +54,10 @@ public async Task ParseAcceptsAutoDetectedReader() public async Task ParseAsyncAcceptsAutoDetectedReader() { await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Bob", 7.0]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + await using IExcelWorkbook reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); var rows = new List(); - await foreach (MoneyRow row in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (MoneyRow row in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { rows.Add(row); } @@ -75,7 +75,7 @@ public async Task PtBrCultureParsesCommaDecimalAndThousandsSeparator() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; - MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader).Single(); + MoneyRow row = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).Single(); Assert.Equal(1234.56m, row.Amount); } @@ -86,7 +86,7 @@ public async Task InvariantCultureRejectsCommaDecimal() await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "1.234,56"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - MoneyRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + MoneyRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(0m, row.Amount); } @@ -100,7 +100,7 @@ public async Task ThrowOnParseFailureThrowsForUnparseableNonRequiredColumn() var config = new ExcelParserConfig { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes(config).Parse(reader).ToList()); + () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); Assert.Equal("Amount", ex.ColumnName); Assert.Equal("not-a-number", ex.RawValue); } @@ -111,7 +111,7 @@ public async Task DefaultConfigLeavesUnparseableNonRequiredColumnAtDefault() await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "not-a-number"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - MoneyRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + MoneyRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(0m, row.Amount); } @@ -127,7 +127,7 @@ public async Task EnumColumnsParseByNameAndNumber() ["Active", 2, id.ToString(), optId.ToString()]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Status.Active, row.Status); Assert.Equal(Status.Closed, row.OptionalStatus); @@ -143,7 +143,7 @@ public async Task EnumIsCaseInsensitiveAndInvalidKeepsDefault() ["active", "garbage"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Status.Active, row.Status); Assert.Null(row.OptionalStatus); @@ -157,7 +157,7 @@ public async Task EnumFromNumericTextCellBindsByValue() ["2", "1"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Status.Closed, row.Status); Assert.Equal(Status.Active, row.OptionalStatus); @@ -171,7 +171,7 @@ public async Task InvalidGuidKeepsDefault() ["not-a-guid", "also-bad"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - TypedRow row = ExcelParser.FromAttributes().Parse(reader).Single(); + TypedRow row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(Guid.Empty, row.Id); Assert.Null(row.OptionalId); @@ -186,7 +186,7 @@ public async Task LongBackedEnumParsesWithoutTruncatingFractionalNumbers() [3_000_000_000.5d]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - LargeEnumRow[] rows = ExcelParser.FromAttributes().Parse(reader).ToArray(); + LargeEnumRow[] rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToArray(); Assert.Equal(LargeStatus.High, rows[0].Status); Assert.Null(rows[1].Status); diff --git a/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs b/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs index 13026a97..120d4447 100644 --- a/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs +++ b/tests/ExcelReader.Tests/Parser/RefStructModelTests.cs @@ -61,7 +61,7 @@ public async Task ParseNamed_SupportsColumnAliasIgnoreAndConverterAttributes() ["Alice", 999, "hello"]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); AttributeRef a = enumerator.Current; Assert.Equal("Alice", a.FirstName); @@ -77,7 +77,7 @@ public async Task ParseNamed_SpanProperty_BindsDirectlyToCellValue() ["Alice", 1]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); SaleSpanRef s = enumerator.Current; Assert.Equal("Alice", Encoding.UTF8.GetString(s.Name)); @@ -94,14 +94,14 @@ public async Task ParseNamed_MatchesClassBasedParser() using var namedReader = Excel.FromXlsx(ms, leaveOpen: true); var namedResults = new List<(string? Name, int Id, double Value, DateTime Date)>(); - foreach (SaleNamedRef s in ExcelParser.FromAttributes().Parse(namedReader)) + foreach (SaleNamedRef s in ExcelParser.FromAttributes().Parse(namedReader.FirstSheet)) { namedResults.Add((s.Name, s.Id, s.Value, s.Date)); } ms.Position = 0; using var classReader = Excel.FromXlsx(ms, leaveOpen: true); - List classResults = ExcelParser.FromAttributes().Parse(classReader).ToList(); + List classResults = ExcelParser.FromAttributes().Parse(classReader.FirstSheet).ToList(); Assert.Equal(2, namedResults.Count); Assert.Equal(classResults.Count, namedResults.Count); @@ -122,7 +122,7 @@ public async Task ParseNamed_IsHeaderOrderIndependent() [SampleDate, 10.5, "Alice", 1]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); SaleNamedRef s = enumerator.Current; Assert.Equal("Alice", s.Name); @@ -139,7 +139,7 @@ public async Task ParseNamed_MissingRequiredColumn_Throws() ["Alice", 10.5, SampleDate]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerable = ExcelParser.FromAttributes().Parse(reader); + var enumerable = ExcelParser.FromAttributes().Parse(reader.FirstSheet); Assert.Throws(() => { var enumerator = enumerable.GetEnumerator(); @@ -155,7 +155,7 @@ public async Task ParseNamed_EmptyCell_YieldsDefaultForThatColumn() ["Alice", 1, new Gap(), SampleDate]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); Assert.Equal(0.0, enumerator.Current.Value); } @@ -168,7 +168,7 @@ public async Task ParseNamed_RequiredColumnWithUnparseableValueThrowsAsIfMissing ["Alice", "oops", 10.5, SampleDate]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); Assert.Throws(() => { _ = enumerator.Current; }); } @@ -182,7 +182,7 @@ public async Task ParseNamed_ThrowOnParseFailureThrowsExcelParseException() var config = new ExcelParserConfig { ThrowOnParseFailure = true }; using var reader = Excel.FromXlsx(ms, leaveOpen: true); - var enumerator = ExcelParser.FromAttributes(config).Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); ExcelParseException ex = Assert.Throws(() => { _ = enumerator.Current; }); Assert.Equal("Id", ex.ColumnName); @@ -199,14 +199,14 @@ public void ParseNamed_Date1904System_ShiftsParsedDateBy1462Days() using var ms1900 = WorkbookBuilder.Build(sheetRows, date1904: false); using var reader1900 = Excel.FromXlsx(ms1900, leaveOpen: true); Assert.False(reader1900.IsDate1904); - var e1900 = ExcelParser.FromAttributes().Parse(reader1900).GetEnumerator(); + var e1900 = ExcelParser.FromAttributes().Parse(reader1900.FirstSheet).GetEnumerator(); Assert.True(e1900.MoveNext()); DateTime date1900 = e1900.Current.Date; using var ms1904 = WorkbookBuilder.Build(sheetRows, date1904: true); using var reader1904 = Excel.FromXlsx(ms1904, leaveOpen: true); Assert.True(reader1904.IsDate1904); - var e1904 = ExcelParser.FromAttributes().Parse(reader1904).GetEnumerator(); + var e1904 = ExcelParser.FromAttributes().Parse(reader1904.FirstSheet).GetEnumerator(); Assert.True(e1904.MoveNext()); DateTime date1904Result = e1904.Current.Date; @@ -223,7 +223,7 @@ public async Task ParseNamed_AwaitForeach_EnumeratesRows() using var reader = Excel.FromXlsx(ms, leaveOpen: true); var results = new List<(string? Name, int Id)>(); - await foreach (SaleNamedRef s in ExcelParser.FromAttributes().Parse(reader)) + await foreach (SaleNamedRef s in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { results.Add((s.Name, s.Id)); } @@ -241,7 +241,7 @@ public async Task IEnumerableOfTInteropWorksAndOnlyTheNonGenericCurrentThrows() ["Alice", 1, 10.5, SampleDate]); using var reader = Excel.FromXlsx(ms, leaveOpen: true); - IEnumerable enumerable = ExcelParser.FromAttributes().Parse(reader); + IEnumerable enumerable = ExcelParser.FromAttributes().Parse(reader.FirstSheet); using IEnumerator generic = enumerable.GetEnumerator(); Assert.True(generic.MoveNext()); Assert.Equal("Alice", generic.Current.Name); diff --git a/tests/ExcelReader.Tests/Parser/RequiredTests.cs b/tests/ExcelReader.Tests/Parser/RequiredTests.cs index 5ee4874c..fa28db6b 100644 --- a/tests/ExcelReader.Tests/Parser/RequiredTests.cs +++ b/tests/ExcelReader.Tests/Parser/RequiredTests.cs @@ -56,7 +56,7 @@ public async Task RequiredColumnsPresentParseNormally() [1, "Alice", "hi"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Row row = ExcelParser.FromAttributes().Parse(reader).Single(); + Row row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(1, row.Id); Assert.Equal("Alice", row.Name); @@ -69,7 +69,7 @@ public async Task OptionalColumnMayBeAbsent() await using var ms = await TypedWorkbook.BuildAsync(["Id", "FullName"], [2, "Bob"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Row row = ExcelParser.FromAttributes().Parse(reader).Single(); + Row row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(2, row.Id); Assert.Equal("Bob", row.Name); @@ -83,7 +83,7 @@ public async Task MissingRequiredColumnThrowsListingAll() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("Id", ex.Message, StringComparison.Ordinal); Assert.Contains("FullName", ex.Message, StringComparison.Ordinal); @@ -95,7 +95,7 @@ public async Task RequiredColumnMatchedByAliasSucceeds() await using var ms = await TypedWorkbook.BuildAsync(["Id", "FullName"], [3, "Carol"]); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - Row row = ExcelParser.FromAttributes().Parse(reader).Single(); + Row row = ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal("Carol", row.Name); } @@ -107,7 +107,7 @@ public async Task EmptyValueInRequiredColumnThrowsWithRowNumber() ("S1", [["Code"], ["A1"], [null]])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - var enumerator = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + var enumerator = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(enumerator.MoveNext()); Assert.Equal("A1", enumerator.Current.Code); @@ -124,7 +124,7 @@ public async Task RequiredColumnWithUnparseableValueThrowsAsIfMissing() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("Id", ex.Message, StringComparison.Ordinal); } @@ -136,7 +136,7 @@ public async Task ThrowOnParseFailureReportsColumnAndRawValueEvenForRequiredColu var config = new ExcelParserConfig { ThrowOnParseFailure = true }; ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes(config).Parse(reader).ToList()); + () => ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList()); Assert.Equal("Id", ex.ColumnName); Assert.Equal("oops", ex.RawValue); } @@ -149,7 +149,7 @@ public async Task AbsentCellInRequiredColumnThrows() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); ExcelParseException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("Code", ex.Message, StringComparison.Ordinal); } @@ -160,7 +160,7 @@ public async Task AllowEmptyRequiresColumnButPermitsBlankValues() ("S1", [["Code"], ["A1"], [null]])); await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); - List rows = [.. ExcelParser.FromAttributes().Parse(reader)]; + List rows = [.. ExcelParser.FromAttributes().Parse(reader.FirstSheet)]; Assert.Equal(2, rows.Count); Assert.Equal("A1", rows[0].Code); @@ -174,7 +174,7 @@ public async Task RequiredPropertyWithNoParserThrows() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); InvalidOperationException ex = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("ExcelRequired", ex.Message, StringComparison.Ordinal); } @@ -198,9 +198,9 @@ public async Task RequiredPropertyWithNoPublicSetterThrows() await using var reader = await Excel.FromXlsxAsync(ms, ct: TestContext.Current.CancellationToken); InvalidOperationException getOnly = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); InvalidOperationException privateSet = Assert.Throws( - () => ExcelParser.FromAttributes().Parse(reader).ToList()); + () => ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList()); Assert.Contains("RequiredGetOnlyRow.Code", getOnly.Message, StringComparison.Ordinal); Assert.Contains("RequiredPrivateSetRow.Code", privateSet.Message, StringComparison.Ordinal); diff --git a/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs b/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs index 80f2b00e..40265324 100644 --- a/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs +++ b/tests/ExcelReader.Tests/Parser/UnifiedParserTests.cs @@ -52,7 +52,7 @@ public void ClassModelStillParsesSynchronously() using CsvReader reader = Excel.FromCsv(Csv); var names = new List(); int ids = 0; - foreach (SaleClass sale in ExcelParser.FromAttributes().Parse(reader)) + foreach (SaleClass sale in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { names.Add(sale.Name); ids += sale.Id; @@ -67,7 +67,7 @@ public void StructModelStillParsesSynchronously() using CsvReader reader = Excel.FromCsv(Csv); double total = 0; int rows = 0; - foreach (SaleStruct sale in ExcelParser.FromAttributes().Parse(reader)) + foreach (SaleStruct sale in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { total += sale.Value; rows++; @@ -82,7 +82,7 @@ public void RefStructModelParsesThroughTheStandardParser() using CsvReader reader = Excel.FromCsv(Csv); var names = new List(); int ids = 0; - foreach (SaleRef sale in ExcelParser.FromAttributes().Parse(reader)) + foreach (SaleRef sale in ExcelParser.FromAttributes().Parse(reader.FirstSheet)) { names.Add(Encoding.UTF8.GetString(sale.Name)); ids += sale.Id; @@ -97,7 +97,7 @@ public async Task RefStructModelParsesAsynchronously() await using CsvReader reader = Excel.FromCsv(Csv); var names = new List(); double total = 0; - await foreach (SaleRef sale in ExcelParser.FromAttributes().Parse(reader).WithCancellation(TestContext.Current.CancellationToken)) + await foreach (SaleRef sale in ExcelParser.FromAttributes().Parse(reader.FirstSheet).WithCancellation(TestContext.Current.CancellationToken)) { names.Add(Encoding.UTF8.GetString(sale.Name)); total += sale.Value; @@ -110,7 +110,7 @@ public async Task RefStructModelParsesAsynchronously() public void RefStructModelFlowsThroughIEnumerableOfT() { using CsvReader reader = Excel.FromCsv(Csv); - IEnumerable sequence = ExcelParser.FromAttributes().Parse(reader); + IEnumerable sequence = ExcelParser.FromAttributes().Parse(reader.FirstSheet); int rows = 0; foreach (SaleRef sale in sequence) { @@ -124,7 +124,7 @@ public void RefStructModelFlowsThroughIEnumerableOfT() public async Task RefStructModelFlowsThroughIAsyncEnumerableOfT() { await using CsvReader reader = Excel.FromCsv(Csv); - IAsyncEnumerable sequence = ExcelParser.FromAttributes().Parse(reader); + IAsyncEnumerable sequence = ExcelParser.FromAttributes().Parse(reader.FirstSheet); int rows = 0; await foreach (SaleRef sale in sequence.WithCancellation(TestContext.Current.CancellationToken)) { @@ -140,7 +140,7 @@ public void RefStructModelParsesThroughExcelMappedParser() using CsvReader reader = Excel.FromCsv(Csv); var names = new List(); int ids = 0; - foreach (MappedSale sale in ExcelParser.Generated().Parse(reader)) + foreach (MappedSale sale in ExcelParser.Generated().Parse(reader.FirstSheet)) { names.Add(Encoding.UTF8.GetString(sale.Name)); ids += sale.Id; @@ -168,7 +168,7 @@ public void RefStructModelParsesThroughExcelFluentParser() var names = new List(); int ids = 0; - foreach (FluentSale sale in parser.Parse(reader)) + foreach (FluentSale sale in parser.Parse(reader.FirstSheet)) { names.Add(Encoding.UTF8.GetString(sale.Name)); ids += sale.Id; @@ -181,7 +181,7 @@ public void RefStructModelParsesThroughExcelFluentParser() public void NonGenericCurrentIsNoLongerSupported() { using CsvReader reader = Excel.FromCsv(Csv); - CsvEnumerable.Enumerator e = ExcelParser.FromAttributes().Parse(reader).GetEnumerator(); + CsvEnumerable.Enumerator e = ExcelParser.FromAttributes().Parse(reader.FirstSheet).GetEnumerator(); Assert.True(e.MoveNext()); System.Collections.IEnumerator legacy = e; Assert.Throws(() => legacy.Current); From aa75f684e82a67cd806a3d87d32bc6f0367e0997 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 17:03:39 -0300 Subject: [PATCH 10/49] test: read through sheets in the writer, crypto, arrow and native tests Co-Authored-By: Claude Sonnet 5.5 --- .../Arrow/ArrowConversionTests.cs | 32 +++++----- .../Arrow/ArrowWriteExtensionsTests.cs | 18 +++--- .../Crypto/EncryptedIntegrityTests.cs | 10 ++-- .../Crypto/EncryptedOpenTests.cs | 14 ++--- .../Crypto/EncryptedPackageWriteTests.cs | 14 ++--- .../Crypto/StandardEncryptionOpenTests.cs | 14 ++--- .../Generator/GeneratorTests.cs | 4 +- .../Native/CsvAggregateNativeTests.cs | 2 +- .../Native/NativeApiTests.Writer.cs | 4 +- .../Native/NativeApiTests.cs | 16 ++--- .../Native/SessionLifetimeTests.cs | 2 +- .../Writer/Csv/CsvWriterOptionsTests.cs | 2 +- .../Writer/Csv/CsvWriterTests.cs | 10 ++-- .../Writer/RecordWriterRoundTripTests.cs | 6 +- .../Writer/SheetWriterFailureTests.cs | 6 +- .../Writer/WorkbookWriterFailureTests.cs | 12 ++-- .../Writer/WorkbookWriterTests.cs | 58 +++++++++---------- .../Writer/WriteOffloadTests.cs | 8 +-- .../Writer/WriterSharedStringTests.cs | 7 +-- .../Writer/WriterStyleTests.cs | 18 +++--- .../Writer/Xls/XlsWriterTests.cs | 27 +++++---- .../Writer/Xlsb/XlsbWriterTests.cs | 20 +++---- 22 files changed, 150 insertions(+), 154 deletions(-) diff --git a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs index 3a4a33af..4778cc96 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs @@ -24,7 +24,7 @@ public void ToArrowRecordBatch_Should_Convert_String_And_Int64_Columns() new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); Assert.Equal(2, batch.Length); Assert.Equal(2, batch.ColumnCount); @@ -43,7 +43,7 @@ public void ToArrowRecordBatch_Should_Append_Null_For_Blank_Cells_On_Nullable_Co using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("qty\n5\n\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "qty", Type = ExcelColumnType.Int64Column, IsNullable = true }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var qty = Assert.IsType(batch.Column(0)); Assert.False(qty.IsNull(0)); @@ -61,7 +61,7 @@ public void ToArrowRecordBatch_Should_Append_Null_For_Blank_Cells_On_Nullable_St new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var name = Assert.IsType(batch.Column(0)); Assert.Equal("widget", name.GetString(0)); @@ -79,7 +79,7 @@ public void ToArrowRecordBatch_Should_Keep_Blank_Cells_As_Empty_Strings_On_NonNu new() { Index = 1, Name = "qty", Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var name = Assert.IsType(batch.Column(0)); Assert.False(name.IsNull(0)); @@ -92,7 +92,7 @@ public void ToArrowRecordBatch_Should_Throw_When_A_NonNullable_Value_Fails_To_Co using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("qty\nnotanumber\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "qty", Type = ExcelColumnType.Int64Column }]; - InvalidOperationException exception = Assert.Throws(() => reader.ToArrowRecordBatch(schema)); + InvalidOperationException exception = Assert.Throws(() => reader.FirstSheet.ToArrowRecordBatch(schema)); Assert.Contains("qty", exception.Message, StringComparison.Ordinal); } @@ -102,7 +102,7 @@ public void ToArrowRecordBatch_Should_Convert_Float64_Column() using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("price\n1.5\n2.25\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "price", Type = ExcelColumnType.Float64Column }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var price = Assert.IsType(batch.Column(0)); Assert.Equal(1.5, price.GetValue(0)); @@ -115,7 +115,7 @@ public void ToArrowRecordBatch_Should_Convert_Bool_Column() using CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes("flag\ntrue\nfalse\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "flag", Type = ExcelColumnType.BoolColumn }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var flag = Assert.IsType(batch.Column(0)); Assert.True(flag.GetValue(0)); @@ -128,7 +128,7 @@ public void ToArrowRecordBatch_Should_Convert_Date_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("day\n2024-01-15\n2024-03-01\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "day", Type = ExcelColumnType.DateColumn }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var day = Assert.IsType(batch.Column(0)); int expectedFirst = (new DateTime(2024, 1, 15) - DateTime.UnixEpoch).Days; @@ -143,7 +143,7 @@ public void ToArrowRecordBatch_Should_Convert_Time_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("clock\n13:30:00\n\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "clock", Type = ExcelColumnType.TimeColumn, IsNullable = true }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var clock = Assert.IsType(batch.Column(0)); Assert.Equal(new TimeSpan(13, 30, 0).Ticks / 10, clock.GetValue(0)); @@ -156,7 +156,7 @@ public void ToArrowRecordBatch_Should_Convert_Timestamp_Column() using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("stamp\n2024-01-15 13:30:00\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "stamp", Type = ExcelColumnType.TimestampColumn }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var stamp = Assert.IsType(batch.Column(0)); long expected = (new DateTime(2024, 1, 15, 13, 30, 0) - DateTime.UnixEpoch).Ticks / 10; @@ -169,7 +169,7 @@ public void ToArrowRecordBatch_Should_Convert_Timestamp_Column_With_Explicit_Utc using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("stamp\n2024-01-15T13:30:00Z\n")); ExcelColumnSchema[] schema = [new() { Index = 0, Name = "stamp", Type = ExcelColumnType.TimestampColumn }]; - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema); var stamp = Assert.IsType(batch.Column(0)); long expected = (new DateTime(2024, 1, 15, 13, 30, 0, DateTimeKind.Utc) - DateTime.UnixEpoch).Ticks / 10; @@ -181,7 +181,7 @@ public void ToArrowRecordBatch_Should_Infer_Schema_When_None_Given() { using var reader = Excel.FromCsv(Encoding.UTF8.GetBytes("name,qty\nwidget,3\ngadget,7\n")); - RecordBatch batch = reader.ToArrowRecordBatch(); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(); Assert.Equal(2, batch.ColumnCount); Assert.Equal("name", batch.Schema.GetFieldByIndex(0).Name); @@ -199,7 +199,7 @@ public void ToArrowRecordBatch_Should_Treat_HeaderRow_Zero_As_No_Header() new() { Index = 1, Type = ExcelColumnType.Int64Column }, ]; - RecordBatch batch = reader.ToArrowRecordBatch(schema, headerRow: 0); + RecordBatch batch = reader.FirstSheet.ToArrowRecordBatch(schema, headerRow: 0); Assert.Equal(2, batch.Length); var name = Assert.IsType(batch.Column(0)); @@ -211,9 +211,9 @@ public void ToArrowRecordBatch_Should_Match_The_Native_Arrow_Path_On_RealExcel() { string path = Path.Combine(AppContext.BaseDirectory, "data", "RealExcel.xlsb"); - using IExcelRowReader managedReader = Excel.Open(path); - ExcelColumnSchema[] schema = Excel.InferSchema(managedReader); - RecordBatch managedBatch = managedReader.ToArrowRecordBatch(schema, headerRow: 1); + using IExcelWorkbook managedReader = Excel.Open(path); + ExcelColumnSchema[] schema = Excel.InferSchema(managedReader.FirstSheet); + RecordBatch managedBatch = managedReader.FirstSheet.ToArrowRecordBatch(schema, headerRow: 1); Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Xlsb, out NativeHandle? handle)); try diff --git a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs index af0b7c8a..db2fbae7 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowWriteExtensionsTests.cs @@ -117,7 +117,7 @@ public void WriteRecordBatch_Xlsx_RoundTrips_Every_Supported_Type() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - RecordBatch roundTripped = reader.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); AssertRoundTrips(roundTripped); } @@ -133,7 +133,7 @@ public void WriteRecordBatch_Xlsb_RoundTrips_Every_Supported_Type() ms.Position = 0; using var reader = Excel.FromXlsb(ms.ToArray()); - RecordBatch roundTripped = reader.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); AssertRoundTrips(roundTripped); } @@ -159,7 +159,7 @@ public void WriteRecordBatch_Xls_RoundTrips_String_And_Numeric_Columns() ms.Position = 0; using var reader = Excel.FromXls(ms.ToArray()); - RecordBatch roundTripped = reader.ToArrowRecordBatch(); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(); var nameCol = Assert.IsType(roundTripped.Column(0)); Assert.Equal("alice", nameCol.GetString(0)); @@ -207,7 +207,7 @@ public void WriteRecordBatch_WithHeaderFalse_OmitsTheHeaderRow() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryParse(null, out long value)); Assert.Equal(3L, value); @@ -285,7 +285,7 @@ private static RecordBatch BuildWideBatch() 2); } - private static void AssertWideRoundTrips(IExcelRowReader reader) + private static void AssertWideRoundTrips(IExcelSheet sheet) { ExcelColumnType[] types = [ @@ -300,7 +300,7 @@ private static void AssertWideRoundTrips(IExcelRowReader reader) { schema[i] = new() { Index = i, Name = "c" + i, Type = types[i], IsNullable = true }; } - RecordBatch batch = reader.ToArrowRecordBatch(schema); + RecordBatch batch = sheet.ToArrowRecordBatch(schema); Assert.Equal(2, batch.Length); long[] integers = [-8, -16, -32, 8, 16, 32, 64]; @@ -332,7 +332,7 @@ public void WriteRecordBatch_Xlsx_RoundTrips_Widened_Types() } using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - AssertWideRoundTrips(reader); + AssertWideRoundTrips(reader.FirstSheet); } [Fact] @@ -345,7 +345,7 @@ public void WriteRecordBatch_Xlsb_RoundTrips_Widened_Types() } using var reader = Excel.FromXlsb(ms.ToArray()); - AssertWideRoundTrips(reader); + AssertWideRoundTrips(reader.FirstSheet); } [Fact] @@ -375,7 +375,7 @@ public async Task WriteRecordBatchAsync_Xlsx_RoundTrips() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms.ToArray()); - RecordBatch roundTripped = reader.ToArrowRecordBatch(ReadBackSchema()); + RecordBatch roundTripped = reader.FirstSheet.ToArrowRecordBatch(ReadBackSchema()); AssertRoundTrips(roundTripped); } diff --git a/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs b/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs index 974f44f8..dad395c7 100644 --- a/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs +++ b/tests/ExcelReader.Tests/Crypto/EncryptedIntegrityTests.cs @@ -8,10 +8,10 @@ public class EncryptedIntegrityTests private static ExcelReaderOptions WithPassword => ExcelReaderOptions.Default with { Password = EncryptedFixtures.Password }; - private static int CountRows(IExcelRowReader reader) + private static int CountRows(IExcelWorkbook reader) { int n = 0; - foreach (Row _ in reader) + foreach (Row _ in reader.FirstSheet) { n++; } @@ -50,7 +50,7 @@ public void Should_Not_Verify_By_Default_On_Streaming_Path() { Assert.False(ExcelReaderOptions.Default.VerifyEncryptedIntegrity); using FileStream fs = File.OpenRead(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")); - using IExcelRowReader reader = Excel.Open(fs, leaveOpen: true, WithPassword); + using IExcelWorkbook reader = Excel.Open(fs, leaveOpen: true, WithPassword); Assert.True(CountRows(reader) > 0); } @@ -59,7 +59,7 @@ public void Should_Verify_When_Opted_In_On_Streaming_Path() { var opted = WithPassword with { VerifyEncryptedIntegrity = true }; using FileStream fs = File.OpenRead(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")); - using IExcelRowReader reader = Excel.Open(fs, leaveOpen: true, opted); + using IExcelWorkbook reader = Excel.Open(fs, leaveOpen: true, opted); Assert.True(CountRows(reader) > 0); } @@ -95,7 +95,7 @@ public void Should_Open_When_Opted_In_But_Descriptor_Has_No_DataIntegrity() try { var opted = WithPassword with { VerifyEncryptedIntegrity = true }; - using IExcelRowReader reader = Excel.Open(temp, opted); + using IExcelWorkbook reader = Excel.Open(temp, opted); Assert.True(CountRows(reader) > 0); } finally diff --git a/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs b/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs index 4d138f4d..ce7cb543 100644 --- a/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs +++ b/tests/ExcelReader.Tests/Crypto/EncryptedOpenTests.cs @@ -22,10 +22,10 @@ public static TheoryData Fixtures() return data; } - private static List> Drain(IExcelRowReader reader) + private static List> Drain(IExcelWorkbook reader) { var rows = new List>(); - foreach (Row row in reader) + foreach (Row row in reader.FirstSheet) { var cells = new List(); foreach (RowCell cell in row.Cells) @@ -41,10 +41,10 @@ private static List> Drain(IExcelRowReader reader) [MemberData(nameof(Fixtures))] public void Should_Read_Same_Rows_As_Plaintext_When_Password_Supplied(string fixture) { - using IExcelRowReader encrypted = Excel.Open(EncryptedFixtures.Path_(fixture), WithPassword(fixture)); + using IExcelWorkbook encrypted = Excel.Open(EncryptedFixtures.Path_(fixture), WithPassword(fixture)); List> fromEncrypted = Drain(encrypted); - using IExcelRowReader plain = Excel.Open(EncryptedFixtures.PlainPath(fixture)); + using IExcelWorkbook plain = Excel.Open(EncryptedFixtures.PlainPath(fixture)); List> fromPlain = Drain(plain); Assert.Equal(fromPlain, fromEncrypted); @@ -55,7 +55,7 @@ public void Should_Read_Same_Rows_As_Plaintext_When_Password_Supplied(string fix [MemberData(nameof(Fixtures))] public void Should_Open_And_Read_Rows_When_Password_Resolved_Via_PasswordFor(string fixture) { - using IExcelRowReader reader = Excel.Open(EncryptedFixtures.Path_(fixture), WithPassword(fixture)); + using IExcelWorkbook reader = Excel.Open(EncryptedFixtures.Path_(fixture), WithPassword(fixture)); Assert.NotEmpty(Drain(reader)); } @@ -63,11 +63,11 @@ public void Should_Open_And_Read_Rows_When_Password_Resolved_Via_PasswordFor(str [MemberData(nameof(Fixtures))] public async Task Should_Read_Same_Rows_When_Opened_Async(string fixture) { - await using IExcelRowReader encrypted = + await using IExcelWorkbook encrypted = await Excel.OpenAsync(EncryptedFixtures.Path_(fixture), WithPassword(fixture), TestContext.Current.CancellationToken); List> fromEncrypted = Drain(encrypted); - using IExcelRowReader plain = Excel.Open(EncryptedFixtures.PlainPath(fixture)); + using IExcelWorkbook plain = Excel.Open(EncryptedFixtures.PlainPath(fixture)); Assert.Equal(Drain(plain), fromEncrypted); } diff --git a/tests/ExcelReader.Tests/Crypto/EncryptedPackageWriteTests.cs b/tests/ExcelReader.Tests/Crypto/EncryptedPackageWriteTests.cs index cc6dec44..fc3da566 100644 --- a/tests/ExcelReader.Tests/Crypto/EncryptedPackageWriteTests.cs +++ b/tests/ExcelReader.Tests/Crypto/EncryptedPackageWriteTests.cs @@ -7,10 +7,10 @@ public sealed class EncryptedPackageWriteTests { private const string Password = "hunter2"; - private static List ReadAllRows(IExcelRowReader reader) + private static List ReadAllRows(IExcelWorkbook reader) { List rows = []; - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); while (e.MoveNext()) { List cells = []; @@ -40,13 +40,13 @@ public void EncryptPackage_RoundTrips_Through_The_Reader(string fixture) byte[] encrypted = Encrypt(plainPath); List expected; - using (IExcelRowReader plainReader = Excel.Open(plainPath)) + using (IExcelWorkbook plainReader = Excel.Open(plainPath)) { expected = ReadAllRows(plainReader); } ExcelReaderOptions options = new() { Password = Password, VerifyEncryptedIntegrity = true }; - using IExcelRowReader decrypted = Excel.Open(encrypted, options); + using IExcelWorkbook decrypted = Excel.Open(encrypted, options); List decryptedRows = ReadAllRows(decrypted); Assert.Equal(expected.Count, decryptedRows.Count); @@ -100,7 +100,7 @@ public async Task EncryptPackageAsync_PathOverload_RoundTrips() await Excel.EncryptPackageAsync(plainPath, outputPath, Password, TestContext.Current.CancellationToken); ExcelReaderOptions options = new() { Password = Password, VerifyEncryptedIntegrity = true }; - using IExcelRowReader reader = Excel.Open(outputPath, options); + using IExcelWorkbook reader = Excel.Open(outputPath, options); Assert.NotEmpty(ReadAllRows(reader)); } finally @@ -121,7 +121,7 @@ public void EncryptPackage_PathOverload_RoundTrips() Excel.EncryptPackage(plainPath, outputPath, Password); ExcelReaderOptions options = new() { Password = Password, VerifyEncryptedIntegrity = true }; - using IExcelRowReader reader = Excel.Open(outputPath, options); + using IExcelWorkbook reader = Excel.Open(outputPath, options); Assert.NotEmpty(ReadAllRows(reader)); } finally @@ -141,7 +141,7 @@ public async Task EncryptPackageAsync_StreamOverload_RoundTrips() byte[] encryptedBytes = encrypted.ToArray(); ExcelReaderOptions options = new() { Password = Password, VerifyEncryptedIntegrity = true }; - using IExcelRowReader decrypted = Excel.Open(encryptedBytes, options); + using IExcelWorkbook decrypted = Excel.Open(encryptedBytes, options); Assert.NotEmpty(ReadAllRows(decrypted)); } diff --git a/tests/ExcelReader.Tests/Crypto/StandardEncryptionOpenTests.cs b/tests/ExcelReader.Tests/Crypto/StandardEncryptionOpenTests.cs index e9ee3a5c..5eb4e256 100644 --- a/tests/ExcelReader.Tests/Crypto/StandardEncryptionOpenTests.cs +++ b/tests/ExcelReader.Tests/Crypto/StandardEncryptionOpenTests.cs @@ -9,10 +9,10 @@ private static byte[] PlainPackage() return EncryptedFixtures.PlainBytes("agile-aes256-sha512.xlsx"); } - private static List ReadAllRows(IExcelRowReader reader) + private static List ReadAllRows(IExcelWorkbook reader) { List rows = []; - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); while (e.MoveNext()) { List cells = []; @@ -32,13 +32,13 @@ public void Should_Match_The_Plaintext_When_Opened_From_Memory() byte[] container = StandardContainerFixture.Forge(plain, StandardContainerFixture.Password); List expected; - using (IExcelRowReader plainReader = Excel.Open(plain)) + using (IExcelWorkbook plainReader = Excel.Open(plain)) { expected = ReadAllRows(plainReader); } ExcelReaderOptions options = new() { Password = StandardContainerFixture.Password }; - using IExcelRowReader reader = Excel.Open(container, options); + using IExcelWorkbook reader = Excel.Open(container, options); List actual = ReadAllRows(reader); Assert.NotEmpty(expected); @@ -60,13 +60,13 @@ public void Should_Match_The_Plaintext_When_Opened_From_A_Stream() File.WriteAllBytes(path, container); List expected; - using (IExcelRowReader plainReader = Excel.Open(plain)) + using (IExcelWorkbook plainReader = Excel.Open(plain)) { expected = ReadAllRows(plainReader); } ExcelReaderOptions options = new() { Password = StandardContainerFixture.Password }; - using IExcelRowReader reader = Excel.Open(path, options); + using IExcelWorkbook reader = Excel.Open(path, options); Assert.Equal(expected.Count, ReadAllRows(reader).Count); } @@ -113,7 +113,7 @@ public void Should_Ignore_VerifyEncryptedIntegrity_When_Scheme_Has_No_Hmac() VerifyEncryptedIntegrity = true, }; - using IExcelRowReader reader = Excel.Open(path, options); + using IExcelWorkbook reader = Excel.Open(path, options); Assert.NotEmpty(ReadAllRows(reader)); } diff --git a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs index 44944a82..3f4a5850 100644 --- a/tests/ExcelReader.Tests/Generator/GeneratorTests.cs +++ b/tests/ExcelReader.Tests/Generator/GeneratorTests.cs @@ -430,7 +430,7 @@ public static async Task RunAsync() writeStream.Position = 0; await using XlsxWorkbook reader = await Excel.FromXlsxAsync(writeStream); var results = new List(); - foreach (Model m in ExcelParser.Generated().Parse(reader)) + foreach (Model m in ExcelParser.Generated().Parse(reader.FirstSheet)) { results.Add(m); } @@ -761,7 +761,7 @@ private static async Task ParseAsync(ExcelParserConfig? config, strin try { var results = new List(); - foreach (T model in ExcelParser.Generated(config).Parse(reader)) + foreach (T model in ExcelParser.Generated(config).Parse(reader.FirstSheet)) { results.Add(describe(model)); } diff --git a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs index c2ef6f67..1602d4a0 100644 --- a/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs +++ b/tests/ExcelReader.Tests/Native/CsvAggregateNativeTests.cs @@ -137,7 +137,7 @@ public void AggregateCsvMemory_Should_Clear_A_Previous_Error_On_Success() private static Row FirstRowOf(string csv) { CsvReader reader = Excel.FromCsv(Encoding.UTF8.GetBytes(csv), CsvReaderOptions.Default); - CsvReader.Enumerator rows = reader.GetEnumerator(); + CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); return rows.Current; } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs index 5213000d..79589575 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs @@ -177,7 +177,7 @@ private static void AssertTemporalColumnsCarryANumberFormat(string path) { using (FileStream file = File.OpenRead(path)) using (XlsxWorkbook reader = Excel.FromXlsx(file)) - using (XlsxWorkbook.Enumerator rows = reader.GetEnumerator()) + using (XlsxWorkbook.Enumerator rows = reader.FirstSheet.GetEnumerator()) { Assert.True(rows.MoveNext()); Assert.True(rows.MoveNext()); @@ -197,7 +197,7 @@ private static double ReadFirstDataSerial(string path) { using FileStream file = File.OpenRead(path); using XlsbWorkbook reader = Excel.FromXlsb(file); - using XlsbWorkbook.Enumerator rows = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.True(rows.MoveNext()); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.cs index 673aa575..98f062ec 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.cs @@ -321,11 +321,11 @@ public void EncryptPackage_Should_Produce_A_File_Openable_With_The_Same_Password Assert.Equal(NativeStatus.Ok, status); - using IExcelRowReader plain = Excel.Open(plainPath); - using IExcelRowReader roundTripped = Excel.Open( + using IExcelWorkbook plain = Excel.Open(plainPath); + using IExcelWorkbook roundTripped = Excel.Open( encryptedPath, new ExcelReaderOptions { Password = EncryptedFixtures.Password }); - using IExcelRowEnumerator plainRows = plain.GetEnumerator(); - using IExcelRowEnumerator roundTrippedRows = roundTripped.GetEnumerator(); + using IExcelRowEnumerator plainRows = plain.FirstSheet.GetEnumerator(); + using IExcelRowEnumerator roundTrippedRows = roundTripped.FirstSheet.GetEnumerator(); Assert.True(plainRows.MoveNext()); Assert.True(roundTrippedRows.MoveNext()); @@ -378,11 +378,11 @@ public void EncryptPackageToMemory_Should_Produce_Bytes_Openable_With_The_Same_P Assert.Equal(NativeStatus.Ok, status); Assert.NotNull(encrypted); - using IExcelRowReader plain = Excel.Open(plainPath); - using IExcelRowReader roundTripped = Excel.Open( + using IExcelWorkbook plain = Excel.Open(plainPath); + using IExcelWorkbook roundTripped = Excel.Open( encrypted, new ExcelReaderOptions { Password = EncryptedFixtures.Password }); - using IExcelRowEnumerator plainRows = plain.GetEnumerator(); - using IExcelRowEnumerator roundTrippedRows = roundTripped.GetEnumerator(); + using IExcelRowEnumerator plainRows = plain.FirstSheet.GetEnumerator(); + using IExcelRowEnumerator roundTrippedRows = roundTripped.FirstSheet.GetEnumerator(); Assert.True(plainRows.MoveNext()); Assert.True(roundTrippedRows.MoveNext()); Assert.Equal(plainRows.Current[0].GetString(), roundTrippedRows.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs index 3e280164..0f674337 100644 --- a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs @@ -11,7 +11,7 @@ namespace ExcelReader.Tests.Native /// /// The one-live-session rule, from the outside. A xl_typed_reader/Arrow stream is the only /// thing in this ABI that holds an IExcelRowEnumerator open ACROSS calls, and - /// serves one usable enumerator at a time — so every + /// serves one usable enumerator at a time — so every /// other read on the same workbook either has to be refused up front or has to invalidate the /// live session loudly. These tests pin both halves; without the interlock they fail by returning /// XL_OK/XL_EOF with silently truncated data. diff --git a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs index 3d748e74..e5c17422 100644 --- a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs +++ b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterOptionsTests.cs @@ -81,7 +81,7 @@ public void Should_RoundTripThroughTheReader_When_BothAreGivenTheSameDialect() var options = CsvReaderOptions.Default with { Delimiter = (byte)';', Encoding = Encoding.Latin1 }; using CsvReader reader = Excel.FromCsv(written.AsMemory(), options); - using CsvReader.Enumerator rows = reader.GetEnumerator(); + using CsvReader.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.True(rows.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs index 3b31c955..63e027f1 100644 --- a/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Csv/CsvWriterTests.cs @@ -246,8 +246,8 @@ public void NumericFieldContainingQuoteByteIsEscaped() } ms.Position = 0; - using IExcelRowReader reader = Excel.FromCsv(ms, options: new CsvReaderOptions { Quote = (byte)'5' }); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = Excel.FromCsv(ms, options: new CsvReaderOptions { Quote = (byte)'5' }); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("155", e.Current[0].GetString()); } @@ -328,8 +328,8 @@ public void WrittenCsvRoundTripsThroughCsvReader() } ms.Position = 0; - using IExcelRowReader reader = Excel.FromCsv(ms); - using IExcelRowEnumerator e = reader.GetEnumerator(); + using IExcelWorkbook reader = Excel.FromCsv(ms); + using IExcelRowEnumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); var header = e.Current; @@ -375,7 +375,7 @@ public async Task RecordWriterRoundTripsThroughCsv() ms.Position = 0; using var reader = Excel.FromCsv(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, parsed.Count); Assert.Equal("has, comma", parsed[0].Name); diff --git a/tests/ExcelReader.Tests/Writer/RecordWriterRoundTripTests.cs b/tests/ExcelReader.Tests/Writer/RecordWriterRoundTripTests.cs index 874a327d..eb1dc5d3 100644 --- a/tests/ExcelReader.Tests/Writer/RecordWriterRoundTripTests.cs +++ b/tests/ExcelReader.Tests/Writer/RecordWriterRoundTripTests.cs @@ -74,10 +74,10 @@ public async Task EveryParsableTypeRoundTripsUnderACommaDecimalCulture(ExcelFile } ms.Position = 0; - using IExcelRowReader reader = Excel.Open(ms, format); + using IExcelWorkbook reader = Excel.Open(ms, format); TextFallbackModel actual = generated - ? ExcelParser.Generated().Parse(reader).Single() - : ExcelParser.FromAttributes().Parse(reader).Single(); + ? ExcelParser.Generated().Parse(reader.FirstSheet).Single() + : ExcelParser.FromAttributes().Parse(reader.FirstSheet).Single(); Assert.Equal(expected.Ratio, actual.Ratio); Assert.Equal(expected.RatioN, actual.RatioN); diff --git a/tests/ExcelReader.Tests/Writer/SheetWriterFailureTests.cs b/tests/ExcelReader.Tests/Writer/SheetWriterFailureTests.cs index 5c2c2559..5614f477 100644 --- a/tests/ExcelReader.Tests/Writer/SheetWriterFailureTests.cs +++ b/tests/ExcelReader.Tests/Writer/SheetWriterFailureTests.cs @@ -263,8 +263,8 @@ await Assert.ThrowsAnyAsync( WriteRow(sheet, "after"); await workbook.DisposeAsync(); - using IExcelRowReader reader = Excel.Open(stream.ToArray().AsMemory()); - using IExcelRowEnumerator rows = reader.GetEnumerator(); + using IExcelWorkbook reader = Excel.Open(stream.ToArray().AsMemory()); + using IExcelRowEnumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal("before", rows.Current[0].GetString()); Assert.True(rows.MoveNext()); @@ -464,7 +464,7 @@ public void Should_KeepTheXlsSheetUsable_When_EndedWhileAContinuationRowIsOpen() sheet.End(); } - using IExcelRowReader reader = Excel.Open(stream.ToArray().AsMemory()); + using IExcelWorkbook reader = Excel.Open(stream.ToArray().AsMemory()); Assert.Equal(2, reader.SheetCount); pool.AssertEveryBufferReturnedOnce(); } diff --git a/tests/ExcelReader.Tests/Writer/WorkbookWriterFailureTests.cs b/tests/ExcelReader.Tests/Writer/WorkbookWriterFailureTests.cs index c5456881..fc9f850f 100644 --- a/tests/ExcelReader.Tests/Writer/WorkbookWriterFailureTests.cs +++ b/tests/ExcelReader.Tests/Writer/WorkbookWriterFailureTests.cs @@ -161,8 +161,8 @@ public async Task Should_ReleaseEverythingOnce_When_EndSucceeds(string format, b { Assert.Equal(1, EndOfCentralDirectoryCount(stream)); } - using IExcelRowReader reader = Excel.Open(stream.ToArray().AsMemory()); - Assert.Equal("S1", reader.SheetNameAt(0)); + using IExcelWorkbook reader = Excel.Open(stream.ToArray().AsMemory()); + Assert.Equal("S1", reader.FirstSheet.Name); Assert.Equal(leaveOpen ? 0 : 1, stream.DisposeCount); pool.AssertEveryBufferReturnedOnce(); } @@ -292,8 +292,8 @@ public async Task Should_StayOpen_When_EndAsyncIsCanceledBeforeItStarts(string f await Assert.ThrowsAnyAsync(() => workbook.EndAsync(new CancellationToken(canceled: true)).AsTask()); await workbook.DisposeAsync(); - using IExcelRowReader reader = Excel.Open(stream.ToArray().AsMemory()); - Assert.Equal("S1", reader.SheetNameAt(0)); + using IExcelWorkbook reader = Excel.Open(stream.ToArray().AsMemory()); + Assert.Equal("S1", reader.FirstSheet.Name); Assert.Equal(!leaveOpen, stream.Disposed); } @@ -326,9 +326,9 @@ public async Task Should_WriteTheActiveSheet_When_ClosedBeforeTheSheetStarted(st } await workbook.DisposeAsync(); - using IExcelRowReader reader = Excel.Open(stream.ToArray().AsMemory()); + using IExcelWorkbook reader = Excel.Open(stream.ToArray().AsMemory()); Assert.Equal(1, reader.SheetCount); - Assert.Equal("Only", reader.SheetNameAt(0)); + Assert.Equal("Only", reader.FirstSheet.Name); } } } diff --git a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs index 99d52279..af1a9491 100644 --- a/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/WorkbookWriterTests.cs @@ -77,7 +77,7 @@ public async Task StringCellRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("Alice", rows[0].Name); } @@ -206,7 +206,7 @@ public async Task RecordWriterRoundTripsAllFormats(RecordFormat format) write => write("People", people)).ConfigureAwait(true); await using var reader = Excel.Open(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, rows.Count); Assert.Equal(1, rows[0].Age); Assert.Equal(2.5, rows[1].Score); @@ -228,7 +228,7 @@ public async Task RecordWriterRoundTripsEnumAndGuidAsText(RecordFormat format) write => write("Data", rows)).ConfigureAwait(true); await using var reader = Excel.Open(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); var row = Assert.Single(parsed); Assert.Equal(Priority.High, row.Priority); Assert.Equal(id, row.Id); @@ -248,13 +248,13 @@ public async Task RecordWriterWritesHeaderOnlyForEmptyRecords() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.False(e.MoveNext()); await using var reader2 = Excel.FromXlsx(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader2).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader2.FirstSheet).ToList(); Assert.Empty(parsed); } @@ -274,7 +274,7 @@ public async Task RecordWriterRoundTripsTimeOnly(RecordFormat format) write => write("Times", rows)).ConfigureAwait(true); await using var reader = Excel.Open(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, parsed.Count); Assert.Equal(new TimeOnly(9, 30, 0), parsed[0].Open); Assert.Equal(new TimeOnly(17, 45, 30), parsed[0].Close); @@ -298,13 +298,13 @@ public async Task RecordWriterSkipsIgnoredProperty() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("Name", e.Current[0].GetString()); Assert.Equal(CellType.Empty, e.Current[1].Type); await using var reader2 = Excel.FromXlsx(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader2).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader2.FirstSheet).ToList(); var row = Assert.Single(parsed); Assert.Equal("Alice", row.Name); Assert.Equal(0, row.Computed); @@ -322,7 +322,7 @@ public async Task RecordWriterUsesExcelConverterOnWrite(RecordFormat format) write => write("Prices", rows)).ConfigureAwait(true); await using var reader = Excel.Open(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, parsed.Count); Assert.Equal(new Money(19.5m), parsed[0].Price); Assert.Equal(new Money(1234.25m), parsed[1].Price); @@ -353,15 +353,14 @@ public async Task RecordWriterWritesHeaderAndDataAcrossSheets() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - var primitives = ExcelParser.FromAttributes().Parse(reader).ToList(); + var primitives = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, primitives.Count); Assert.Equal(1, primitives[0].Age); Assert.Equal(20m, primitives[1].Balance); Assert.False(primitives[1].Active); await using var reader2 = Excel.FromXlsx(ms); - reader2.MoveToSheet(1); - var strings = ExcelParser.FromAttributes().Parse(reader2).ToList(); + var strings = ExcelParser.FromAttributes().Parse(reader2.Sheets[1]).ToList(); Assert.Equal(["Alice", "Bob"], strings.Select(r => r.Name), StringComparer.Ordinal); } @@ -381,7 +380,7 @@ public async Task RecordWriterAsyncEnumerableAndAliasHeader() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(["Alice", "Bob"], rows.Select(r => r.Name), StringComparer.Ordinal); } @@ -405,7 +404,7 @@ public async Task RecordWriterHandlesNullableColumns() ms.Position = 0; await using var reader = Excel.Open(ms); - var parsed = ExcelParser.FromAttributes().Parse(reader).ToList(); + var parsed = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(2, parsed.Count); Assert.Equal(7, parsed[0].Quantity); Assert.Equal(new DateTime(2020, 5, 6, 0, 0, 0, DateTimeKind.Unspecified), parsed[0].EventDate); @@ -459,7 +458,7 @@ public async Task AllPrimitiveTypesRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal(42, rows[0].Age); Assert.Equal(3.14, rows[0].Score, precision: 10); @@ -489,7 +488,7 @@ public async Task BoolFalseRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.False(rows[0].Active); } @@ -515,7 +514,7 @@ public async Task NullStringWritesEmptyCell() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Null(rows[0].Name); } @@ -543,7 +542,7 @@ public async Task NullableIntFilledRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal(77, rows[0].Quantity); Assert.Null(rows[0].EventDate); @@ -574,7 +573,7 @@ public async Task NullableDateTimeRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Null(rows[0].Quantity); Assert.NotNull(rows[0].EventDate); @@ -607,7 +606,7 @@ public async Task NullableAndGenericNumericOverloadsRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("1234567890123", e.Current[0].GetString()); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -647,7 +646,7 @@ public async Task MultipleRowsRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(5, rows.Count); Assert.Equal("Alice", rows[0].Name); Assert.Equal("Bob", rows[1].Name); @@ -687,13 +686,12 @@ public async Task MultipleSheetsRoundTrip() await using var reader = Excel.FromXlsx(ms); var parser = ExcelParser.FromAttributes(); - var rowsSheet1 = parser.Parse(reader).ToList(); + var rowsSheet1 = parser.Parse(reader.FirstSheet).ToList(); Assert.Single(rowsSheet1); Assert.Equal("FromAlpha", rowsSheet1[0].Value); await using var reader2 = Excel.FromXlsx(ms); - reader2.MoveToSheet(1); - var rowsSheet2 = parser.Parse(reader2).ToList(); + var rowsSheet2 = parser.Parse(reader2.Sheets[1]).ToList(); Assert.Single(rowsSheet2); Assert.Equal("FromBeta", rowsSheet2[0].Value); } @@ -724,7 +722,7 @@ public async Task SkipCreatesColumnGap() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("aaa", rows[0].A); Assert.Equal("ccc", rows[0].C); @@ -775,7 +773,7 @@ public async Task XmlSpecialCharsAreEscaped() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("", rows[0].Name); } @@ -803,7 +801,7 @@ public async Task DisposeAsyncWithoutEndAsyncProducesReadableWorkbook() ms.Position = 0; await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("Auto", rows[0].Name); } @@ -836,7 +834,7 @@ public async Task HeaderRowTwoConfig() await using var reader = Excel.FromXlsx(ms); var config = new ExcelParserConfig { HeaderRow = 2 }; - var rows = ExcelParser.FromAttributes(config).Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes(config).Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("HeaderRow2", rows[0].Name); } @@ -908,7 +906,7 @@ public async Task LargeWorkbookRoundTrip() }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Equal(rowCount, rows.Count); for (int i = 0; i < rowCount; i++) { @@ -996,7 +994,7 @@ await genericSheet.WriteRecordsAsync( }).ConfigureAwait(true); await using var reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); for (int expected = 1; expected <= 4; expected++) { Assert.True(e.MoveNext()); diff --git a/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs b/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs index c8608a6d..93537160 100644 --- a/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriteOffloadTests.cs @@ -21,7 +21,7 @@ public async Task XlsxPrefetchWriteReadsBackIdenticalCells() using MemoryStream ms = new(bytes, writable: false); using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -41,7 +41,7 @@ public async Task XlsbPrefetchWriteReadsBackIdenticalCells() using MemoryStream ms = new(bytes, writable: false); using XlsbWorkbook reader = Excel.FromXlsb(ms); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -73,7 +73,7 @@ public async Task XlsxPrefetchWriteWorksThroughSyncRowApi() ms.Position = 0; using XlsxWorkbook reader = Excel.FromXlsx(ms); - using XlsxWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { @@ -103,7 +103,7 @@ public async Task XlsbPrefetchWriteRoundTripsASmallSheetThatNeverSpills() ms.Position = 0; using XlsbWorkbook reader = Excel.FromXlsb(ms); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); int rowIndex = 0; while (e.MoveNext()) { diff --git a/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs b/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs index c53ac8f3..f371fc9e 100644 --- a/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriterSharedStringTests.cs @@ -90,15 +90,14 @@ public async Task XlsxWriterOptInSharedStringsDeduplicatesAndRoundTrips() workbook.Position = 0; await using XlsxWorkbook reader = Excel.FromXlsx(workbook); - using XlsxWorkbook.Enumerator rows = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal("repeat", rows.Current[0].GetString()); Assert.Equal("repeat", rows.Current[1].GetString()); Assert.Equal(" spaced ", rows.Current[2].GetString()); Assert.False(rows.MoveNext()); - reader.MoveToSheet(1); - using XlsxWorkbook.Enumerator secondRows = reader.GetEnumerator(); + using XlsxWorkbook.Enumerator secondRows = reader.Sheets[1].GetEnumerator(); Assert.True(secondRows.MoveNext()); Assert.Equal("repeat", secondRows.Current[0].GetString()); } @@ -202,7 +201,7 @@ public async Task XlsbWriterOptInSharedStringsDeduplicatesAndRoundTrips() workbook.Position = 0; await using XlsbWorkbook reader = Excel.FromXlsb(workbook); - using XlsbWorkbook.Enumerator rows = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator rows = reader.FirstSheet.GetEnumerator(); Assert.True(rows.MoveNext()); Assert.Equal(CellType.ExcelString, rows.Current[0].Type); Assert.Equal("repeat", rows.Current[0].GetString()); diff --git a/tests/ExcelReader.Tests/Writer/WriterStyleTests.cs b/tests/ExcelReader.Tests/Writer/WriterStyleTests.cs index 9ec5f4bb..c8654697 100644 --- a/tests/ExcelReader.Tests/Writer/WriterStyleTests.cs +++ b/tests/ExcelReader.Tests/Writer/WriterStyleTests.cs @@ -77,7 +77,7 @@ public async Task DateCellsKeepStyleIndexOneXlsx() await sheet.EndAsync(TestContext.Current.CancellationToken); }); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(1, e.Current[0].StyleIndex); } @@ -99,7 +99,7 @@ public async Task DateCellsKeepStyleIndexOneXlsb() } ms.Position = 0; await using var reader = Excel.FromXlsb(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(1, e.Current[0].StyleIndex); } @@ -121,7 +121,7 @@ public async Task DateCellsKeepStyleIndexOneXls() } ms.Position = 0; using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(XlsGlobals.DateXf, e.Current[0].StyleIndex); } @@ -190,7 +190,7 @@ public async Task ColumnStyleRoundTripsThroughReaderXlsx() await sheet.EndAsync(TestContext.Current.CancellationToken); }); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(2, e.Current[0].StyleIndex); } @@ -214,7 +214,7 @@ public async Task ColumnStyleRoundTripsThroughReaderXlsb() } ms.Position = 0; await using var reader = Excel.FromXlsb(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(styleId, e.Current[0].StyleIndex); } @@ -238,7 +238,7 @@ public async Task ColumnStyleRoundTripsThroughReaderXls() } ms.Position = 0; using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(XlsGlobals.CustomXf(styleId), e.Current[0].StyleIndex); } @@ -274,7 +274,7 @@ public async Task RowStyleRoundTripsThroughReaderXlsx() await sheet.EndAsync(TestContext.Current.CancellationToken); }); using var reader = Excel.FromXlsx(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(2, e.Current[0].StyleIndex); } @@ -297,7 +297,7 @@ public async Task RowStyleRoundTripsThroughReaderXlsb() } ms.Position = 0; await using var reader = Excel.FromXlsb(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(styleId, e.Current[0].StyleIndex); } @@ -320,7 +320,7 @@ public async Task RowStyleRoundTripsThroughReaderXls() } ms.Position = 0; using var reader = Excel.FromXls(ms); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(XlsGlobals.CustomXf(styleId), e.Current[0].StyleIndex); } diff --git a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs index a5f02729..acdd2aab 100644 --- a/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xls/XlsWriterTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using ExcelReader.Core.Reader; +using ExcelReader.Core.Reader.Xls; using ExcelReader.Core.Writer.Xls; namespace ExcelReader.Tests.Writer.Xls @@ -40,7 +41,7 @@ public async Task RoundTripsAllCellTypes() }, ct: ct); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("João", e.Current[0].GetString()); Assert.True(e.Current[1].TryParse(Inv, out int age)); @@ -78,7 +79,7 @@ public async Task NumericOverloadsRoundTrip() }, ct: ct); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryParse(Inv, out int intValue)); Assert.Equal(123, intValue); @@ -125,7 +126,7 @@ public async Task NullableAndGenericOverloadsRoundTrip() }, ct: ct); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -163,7 +164,7 @@ public async Task NullsAndSkipLeaveCellsEmpty() }, ct: ct); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("A", e.Current[0].GetString()); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -189,12 +190,11 @@ public async Task WritesMultipleSheetsWithCorrectOffsets() using var reader = Excel.FromXls(new MemoryStream(bytes)); Assert.Equal(2, reader.SheetCount); - reader.MoveToSheet(0); - Assert.Equal("First", reader.SheetName); - using (var e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("one", e.Current[0].GetString()); } + Assert.Equal("First", reader.Sheets[0].Name); + using (var e = reader.Sheets[0].GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("one", e.Current[0].GetString()); } - Assert.True(reader.TryMoveToSheet("Ωmega")); - using (var e = reader.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("two", e.Current[0].GetString()); } + Assert.True(reader.TryGetSheet("Ωmega", out XlsSheet omega)); + using (var e = omega.GetEnumerator()) { Assert.True(e.MoveNext()); Assert.Equal("two", e.Current[0].GetString()); } } [Fact] @@ -217,7 +217,7 @@ public async Task ManyRowsRoundTripWithExactCount() Assert.True(bytes.Length > 512 * 4, "workbook should span several OLE sectors"); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); int read = 0; while (e.MoveNext()) { @@ -245,7 +245,7 @@ public async Task Date1904RoundTrips() using var reader = Excel.FromXls(new MemoryStream(bytes)); Assert.True(reader.IsDate1904); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); Assert.Equal(date, parsed); @@ -351,7 +351,7 @@ public async Task LargeWorkbookForcesDifatSectors() Assert.True(bytes.Length > 7 * 1024 * 1024, "workbook must exceed the DIFAT threshold"); using var reader = Excel.FromXls(new MemoryStream(bytes)); - using var e = reader.GetEnumerator(); + using var e = reader.FirstSheet.GetEnumerator(); int read = 0; while (e.MoveNext()) { @@ -389,8 +389,7 @@ public async Task RowOverflowAutoSplitsIntoNewSheet() int totalRows = 0; for (int sheet = 0; sheet < reader.SheetCount; sheet++) { - reader.MoveToSheet(sheet); - using var e = reader.GetEnumerator(); + using var e = reader.Sheets[sheet].GetEnumerator(); while (e.MoveNext()) { totalRows++; diff --git a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs index f6ada623..dc4fd2c4 100644 --- a/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/Writer/Xlsb/XlsbWriterTests.cs @@ -58,7 +58,7 @@ public async Task RoundTripsThroughReaderAndParser() }); await using var reader = Excel.FromXlsb(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("Alice", rows[0].Name); Assert.Equal(42, rows[0].Age); @@ -105,7 +105,7 @@ public async Task SkipCreatesColumnGap() }); await using var reader = Excel.FromXlsb(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("aaa", rows[0].A); Assert.Equal("ccc", rows[0].C); @@ -127,7 +127,7 @@ public async Task Date1904RoundTrips() await using var reader = Excel.FromXlsb(ms); Assert.True(reader.IsDate1904); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); Assert.Equal(date, parsed); @@ -155,7 +155,7 @@ public async Task NumericOverloadsRoundTrip() }); await using var reader = Excel.FromXlsb(ms); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryParse(null, out int intValue)); Assert.Equal(123, intValue); @@ -201,7 +201,7 @@ public async Task NullableAndGenericOverloadsRoundTrip() }); await using var reader = Excel.FromXlsb(ms); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal(CellType.Boolean, e.Current[0].Type); Assert.Equal(CellType.Empty, e.Current[1].Type); @@ -236,7 +236,7 @@ public async Task NegativeSkipThrows() }); await using var reader = Excel.FromXlsb(ms); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.Equal("ok", e.Current[0].GetString()); Assert.False(e.MoveNext()); @@ -280,7 +280,7 @@ public async Task BulkXlsbCellRowRoundTrip() }); await using var reader = Excel.FromXlsb(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("Alice", rows[0].Name); Assert.Equal(42, rows[0].Age); @@ -302,7 +302,7 @@ public async Task BulkXlsbCellRowPreservesEmptyCells() }); await using var reader = Excel.FromXlsb(ms); - var rows = ExcelParser.FromAttributes().Parse(reader).ToList(); + var rows = ExcelParser.FromAttributes().Parse(reader.FirstSheet).ToList(); Assert.Single(rows); Assert.Equal("aaa", rows[0].A); Assert.Equal("ccc", rows[0].C); @@ -322,7 +322,7 @@ public async Task BulkXlsbCellDate1904RoundTrips() await using var reader = Excel.FromXlsb(ms); Assert.True(reader.IsDate1904); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); Assert.Equal(date, parsed); @@ -461,7 +461,7 @@ public async Task NegativeZeroKeepsRealCellSoItsSignSurvives() ms.Position = 0; await using XlsbWorkbook reader = Excel.FromXlsb(ms, leaveOpen: true); - using XlsbWorkbook.Enumerator e = reader.GetEnumerator(); + using XlsbWorkbook.Enumerator e = reader.FirstSheet.GetEnumerator(); Assert.True(e.MoveNext()); Assert.True(e.Current[0].TryGetDouble(out double readBack)); return (cells, readBack); From 5a523d1896bccda4ad6df68116e19891da9e587b Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 17:06:57 -0300 Subject: [PATCH 11/49] test(core): build the parser test enumerable from a sheet Co-Authored-By: Claude Sonnet 5.5 --- tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs index 87c5fea2..80f4606f 100644 --- a/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs +++ b/tests/ExcelReader.Tests/Parser/ExcelRowMapBuilderTests.cs @@ -97,7 +97,7 @@ public async Task RequiredColumnMissingThrowsLikeReflection() TypeMapInfo info = builder.Build(); await using XlsxWorkbook mappedReader = await Excel.FromXlsxAsync(mappedStream, ct: TestContext.Current.CancellationToken); - var mappedEnumerable = new ExcelEnumerable(mappedReader, new ExcelParserConfig(), info); + var mappedEnumerable = new ExcelEnumerable(mappedReader.FirstSheet, new ExcelParserConfig(), info); ExcelParseException mappedEx = Assert.Throws(() => mappedEnumerable.ToList()); await using MemoryStream reflectedStream = await TypedWorkbook.BuildAsync(["Name"], ["Alice"]); From 4791c31b33d94454798660672183818d8930a78a Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 17:35:40 -0300 Subject: [PATCH 12/49] feat(core)!: replace the row reader and its current sheet with workbooks and sheets The v5 "reader with a current sheet" surface is gone; only the workbook/sheet API remains. - IExcelRowReader, IExcelRowReader -> IExcelWorkbook (the workbook) or IExcelSheet / IExcelSheet (one sheet) - ExcelRowReaderExtensions.Sheets(), ExcelSheet -> for n in [0, SheetCount): workbook.SheetAt(n), or foreach over workbook.Sheets - MoveToSheet(int) -> Sheets[n] / SheetAt(n) - TryMoveToSheet(name) -> TryGetSheet(name, out sheet) - SheetName, SheetNameAt(n) -> sheet.Name - SheetVisibility, SheetVisibilityAt(n) -> sheet.Visibility - workbook GetEnumerator() / GetAsyncEnumerator(ct) -> sheet.GetEnumerator() / sheet.GetAsyncEnumerator(ct) - Excel.Open / OpenAsync returning IExcelRowReader -> returning IExcelWorkbook / ValueTask - Excel.InferSchema(IExcelRowReader, ...) -> Excel.InferSchema(IExcelSheet, ...) - ExcelParser.Parse(XlsxWorkbook | XlsWorkbook | XlsbWorkbook | IExcelRowReader | CsvReader) -> Parse(XlsxSheet | XlsSheet | XlsbSheet | IExcelSheet | CsvSheet) - ExcelDataReader(IExcelRowReader, int) -> ExcelDataReader(IExcelSheet, int) - ToArrowRecordBatch(this IExcelRowReader, ...) -> ToArrowRecordBatch(this IExcelSheet, ...) Co-Authored-By: Claude Opus 5.5 --- .../ArrowConversionExtensions.cs | 43 +----- src/ExcelReader.Arrow/ArrowWriteExtensions.cs | 2 +- src/ExcelReader.Arrow/ColumnAppender.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + .../Parser/ExcelEnumerable.cs | 6 +- src/ExcelReader.Core/Parser/ExcelParser.cs | 62 +-------- .../PublicAPI/PublicAPI.Unshipped.txt | 89 ++++++++---- src/ExcelReader.Core/Reader/Csv/CsvReader.cs | 78 +---------- src/ExcelReader.Core/Reader/Excel.cs | 128 ++++++------------ .../Reader/ExcelDataReader.cs | 56 ++------ .../Reader/ExcelRowReaderExtensions.cs | 44 ------ .../Reader/IExcelRowEnumerator.cs | 21 +++ .../Reader/IExcelRowReader.cs | 81 ----------- .../Reader/Schema/ExcelColumnSchema.cs | 2 +- .../Reader/Schema/ExcelColumnType.cs | 2 +- .../Reader/Schema/SchemaInference.cs | 2 +- .../Reader/Xls/XlsWorkbook.cs | 67 +-------- .../Reader/Xlsb/XlsbWorkbook.cs | 64 +-------- .../Reader/Xlsx/XlsxWorkbook.cs | 62 +-------- .../Reader/ExcelRowReaderExtensionsTests.cs | 62 --------- 20 files changed, 162 insertions(+), 712 deletions(-) delete mode 100644 src/ExcelReader.Core/Reader/ExcelRowReaderExtensions.cs create mode 100644 src/ExcelReader.Core/Reader/IExcelRowEnumerator.cs delete mode 100644 src/ExcelReader.Core/Reader/IExcelRowReader.cs delete mode 100644 tests/ExcelReader.Tests/Reader/ExcelRowReaderExtensionsTests.cs diff --git a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs index 621a9ca5..4c311277 100644 --- a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs @@ -4,36 +4,9 @@ namespace ExcelReader.Arrow { - /// Converts an 's current sheet into an Apache Arrow . + /// Converts an into an Apache Arrow . public static class ArrowConversionExtensions { - /// - /// Reads 's current sheet into one , entirely - /// in memory. - /// - /// The reader whose current sheet is converted. - /// - /// The column shape to convert to. When , resolved via - /// using . - /// - /// - /// 1-based header row, reused both for schema inference (when is - /// ) and to skip the header line during the data pass. 0 means no header row. - /// - /// is . - /// - /// A cell failed to convert to its column's declared type on a non-nullable column. - /// - /// The sheet has fewer rows than . - /// names an unrecognized . - public static RecordBatch ToArrowRecordBatch(this IExcelRowReader reader, ExcelColumnSchema[]? schema = null, int headerRow = 1) - { - ArgumentNullException.ThrowIfNull(reader); - ExcelColumnSchema[] resolvedSchema = schema ?? Excel.InferSchema(reader, headerRow); - using IExcelRowEnumerator rows = reader.GetEnumerator(); - return Build(rows, reader.IsDate1904, resolvedSchema, headerRow); - } - /// Converts one sheet into an Arrow . /// The sheet to convert. /// The column schema to use; inferred from the sheet with when . @@ -44,16 +17,12 @@ public static RecordBatch ToArrowRecordBatch(this IExcelSheet sheet, ExcelColumn { ArgumentNullException.ThrowIfNull(sheet); schema ??= Excel.InferSchema(sheet, headerRow); + bool isDate1904 = sheet.IsDate1904; using IExcelRowEnumerator rows = sheet.GetEnumerator(); - return Build(rows, sheet.IsDate1904, schema, headerRow); - } - - private static RecordBatch Build(IExcelRowEnumerator rows, bool isDate1904, ExcelColumnSchema[] resolvedSchema, int headerRow) - { - ColumnAppender[] appenders = new ColumnAppender[resolvedSchema.Length]; - for (int i = 0; i < resolvedSchema.Length; i++) + ColumnAppender[] appenders = new ColumnAppender[schema.Length]; + for (int i = 0; i < schema.Length; i++) { - appenders[i] = ColumnAppender.Create(resolvedSchema[i]); + appenders[i] = ColumnAppender.Create(schema[i]); } SkipHeaderRow(rows, headerRow); @@ -64,7 +33,7 @@ private static RecordBatch Build(IExcelRowEnumerator rows, bool isDate1904, Exce Row row = rows.Current; for (int i = 0; i < appenders.Length; i++) { - appenders[i].Append(row[resolvedSchema[i].Index], isDate1904); + appenders[i].Append(row[schema[i].Index], isDate1904); } rowCount++; } diff --git a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs index 5e1b0178..0b74138b 100644 --- a/src/ExcelReader.Arrow/ArrowWriteExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowWriteExtensions.cs @@ -11,7 +11,7 @@ namespace ExcelReader.Arrow { /// /// Writes an Apache Arrow to a workbook as one sheet — the write-side - /// mirror of . + /// mirror of . /// public static class ArrowWriteExtensions { diff --git a/src/ExcelReader.Arrow/ColumnAppender.cs b/src/ExcelReader.Arrow/ColumnAppender.cs index 254fb846..4ab6ae78 100644 --- a/src/ExcelReader.Arrow/ColumnAppender.cs +++ b/src/ExcelReader.Arrow/ColumnAppender.cs @@ -7,7 +7,7 @@ namespace ExcelReader.Arrow { - /// Builds one Arrow column from a schema-driven pass over an 's rows. + /// Builds one Arrow column from a schema-driven pass over an 's rows. internal abstract class ColumnAppender(ExcelColumnSchema schema) { protected readonly bool IsNullable = schema.IsNullable; diff --git a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt index 3d76c3e8..38a178ff 100644 --- a/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Arrow/PublicAPI/PublicAPI.Unshipped.txt @@ -1,2 +1,3 @@ #nullable enable static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelSheet! sheet, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! +*REMOVED*static ExcelReader.Arrow.ArrowConversionExtensions.ToArrowRecordBatch(this ExcelReader.Core.Reader.IExcelRowReader! reader, ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]? schema = null, int headerRow = 1) -> Apache.Arrow.RecordBatch! diff --git a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs index f27915c3..540fa59c 100644 --- a/src/ExcelReader.Core/Parser/ExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/ExcelEnumerable.cs @@ -9,11 +9,11 @@ namespace ExcelReader.Core.Parser { /// Lazily projects XLSX rows into instances, for both synchronous and asynchronous enumeration. /// The row model type to bind each row to. - public sealed class ExcelEnumerable : ExcelEnumerable + public sealed class ExcelEnumerable : ExcelEnumerable where T : allows ref struct { - internal ExcelEnumerable(XlsxWorkbook reader, ExcelParserConfig config, TypeMapInfo explicitInfo) - : base(reader, config, explicitInfo) + internal ExcelEnumerable(XlsxSheet sheet, ExcelParserConfig config, TypeMapInfo explicitInfo) + : base(sheet, config, explicitInfo) { } } diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 566f39f5..fe590f18 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Parser { /// - /// Parses rows from an Excel or CSV reader into instances of . Create one with + /// Parses rows from an Excel or CSV sheet into instances of . Create one with /// : reflects over /// [ExcelColumn]/[ExcelRequired]/[ExcelConverter] attributes, /// uses the [ExcelSerializable] source-generated map, and @@ -38,68 +38,12 @@ internal ExcelParser(ExcelParserConfig config, Func> info, Func CsvInfo => _csvInfo(); - /// Parses the rows of an XLSX reader into instances, lazily as the result is enumerated. - /// The XLSX reader to pull rows from. - /// An enumerable that yields one per data row. - /// is . - public ExcelEnumerable Parse(XlsxWorkbook reader) - { - ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); - } - - /// Parses the rows of an XLS reader into instances, lazily as the result is enumerated. - /// The XLS reader to pull rows from. - /// An enumerable that yields one per data row. - /// is . - public ExcelEnumerable Parse(XlsWorkbook reader) - { - ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); - } - - /// Parses the rows of an XLSB reader into instances, lazily as the result is enumerated. - /// The XLSB reader to pull rows from. - /// An enumerable that yields one per data row. - /// is . - public ExcelEnumerable Parse(XlsbWorkbook reader) - { - ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); - } - - /// Parses the rows of a format-agnostic reader (e.g. one returned by Excel.Open) into instances, lazily as the result is enumerated. Lets callers avoid pattern-matching the concrete reader type; dispatches through the interface enumerator. - /// The reader to pull rows from. - /// An enumerable that yields one per data row. - /// is . - public ExcelEnumerable Parse(IExcelRowReader reader) - { - ArgumentNullException.ThrowIfNull(reader); - return new ExcelEnumerable(reader, _config, _info()); - } - - /// Parses the rows of a CSV reader into instances, lazily as the result is enumerated. - /// The CSV reader to pull rows from. - /// An enumerable that yields one per data row. - /// is . - /// - /// Uses a specialized enumerable (dense field binding, single-pass projection) rather than the - /// generic , since CSV rows have no gaps or styles. Prefer this - /// concrete overload over for CSV — holding the reader as - /// instead routes through the generic path. - /// - public CsvEnumerable Parse(CsvReader reader) - { - ArgumentNullException.ThrowIfNull(reader); - return new CsvEnumerable(reader.Sheets[0], _config, _csvInfo()); - } - /// Parses the rows of an XLSX sheet into instances, lazily as the result is enumerated. /// The sheet to pull rows from. Each enumeration of the result starts a new read of it. /// An enumerable that yields one per data row. - public ExcelEnumerable Parse(XlsxSheet sheet) + public ExcelEnumerable Parse(XlsxSheet sheet) { - return new ExcelEnumerable(sheet, _config, _info()); + return new ExcelEnumerable(sheet, _config, _info()); } /// Parses the rows of an XLS sheet into instances, lazily as the result is enumerated. diff --git a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt index 5ffd7a89..9f1f112d 100644 --- a/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/ExcelReader.Core/PublicAPI/PublicAPI.Unshipped.txt @@ -5,10 +5,41 @@ *REMOVED*ExcelReader.Core.Parser.ExcelEnumerable *REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator *REMOVED*ExcelReader.Core.Parser.ExcelEnumerable.Enumerator +*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvReader! reader) -> ExcelReader.Core.Parser.CsvEnumerable! +*REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelRowReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! *REMOVED*ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxReader! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.GetEnumerator() -> ExcelReader.Core.Reader.Csv.CsvReader.Enumerator! +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.MoveToSheet(int index) -> void +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetName.get -> string! +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetNameAt(int index) -> string! +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.Csv.CsvReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*ExcelReader.Core.Reader.ExcelDataReader.ExcelDataReader(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1) -> void +*REMOVED*ExcelReader.Core.Reader.ExcelRowReaderExtensions +*REMOVED*ExcelReader.Core.Reader.ExcelSheet +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Deconstruct(out int Index, out string! Name) -> void +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Equals(ExcelReader.Core.Reader.ExcelSheet other) -> bool +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.ExcelSheet() -> void +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.ExcelSheet(int Index, string! Name) -> void +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Index.get -> int +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Index.init -> void +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Name.get -> string! +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Name.init -> void +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Visibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.ExcelSheet.Visibility.init -> void +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.MoveToSheet(int index) -> void *REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetCount.get -> int +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetName.get -> string! +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetNameAt(int index) -> string! +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*ExcelReader.Core.Reader.IExcelRowReader *REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> TEnumerator *REMOVED*ExcelReader.Core.Reader.IExcelRowReader.GetEnumerator() -> TEnumerator *REMOVED*ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool @@ -65,6 +96,7 @@ *REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility *REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility *REMOVED*ExcelReader.Core.Reader.Xlsx.XlsxReader.TryMoveToSheet(System.ReadOnlySpan name) -> bool +*REMOVED*override ExcelReader.Core.Reader.ExcelSheet.GetHashCode() -> int *REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! *REMOVED*static ExcelReader.Core.Reader.Excel.FromXls(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xls.XlsReader! *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -80,9 +112,26 @@ *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.Xlsx.XlsxReader! *REMOVED*static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +*REMOVED*static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelRowReader! reader, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelRowReader! +*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +*REMOVED*static ExcelReader.Core.Reader.ExcelRowReaderExtensions.Sheets(this ExcelReader.Core.Reader.IExcelRowReader! reader) -> System.Collections.Generic.IEnumerable! +*REMOVED*static ExcelReader.Core.Reader.ExcelSheet.operator !=(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool +*REMOVED*static ExcelReader.Core.Reader.ExcelSheet.operator ==(ExcelReader.Core.Reader.ExcelSheet left, ExcelReader.Core.Reader.ExcelSheet right) -> bool *REMOVED*virtual ExcelReader.Core.Parser.AsyncRowEnumerator.DisposeAsync() -> System.Threading.Tasks.ValueTask *REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Parser.ExcelEnumerable.AsyncEnumerator! *REMOVED*~ExcelReader.Core.Parser.ExcelEnumerable.GetEnumerator() -> ExcelReader.Core.Parser.ExcelEnumerable.Enumerator! +*REMOVED*~override ExcelReader.Core.Reader.ExcelSheet.Equals(object obj) -> bool +*REMOVED*~override ExcelReader.Core.Reader.ExcelSheet.ToString() -> string ExcelReader.Core.Parser.AsyncRowEnumerator ExcelReader.Core.Parser.AsyncRowEnumerator.Current.get -> T ExcelReader.Core.Parser.AsyncRowEnumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask @@ -92,11 +141,8 @@ ExcelReader.Core.Parser.ExcelEnumerable.Enumerator ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> ExcelReader.Core.Parser.CsvEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.IExcelSheet! sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xls.XlsWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsb.XlsbWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! -ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxWorkbook! reader) -> ExcelReader.Core.Parser.ExcelEnumerable! +ExcelReader.Core.Parser.ExcelParser.Parse(ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> ExcelReader.Core.Parser.ExcelEnumerable! ExcelReader.Core.Reader.Csv.CsvReader.FirstSheet.get -> ExcelReader.Core.Reader.Csv.CsvSheet ExcelReader.Core.Reader.Csv.CsvReader.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! ExcelReader.Core.Reader.Csv.CsvReader.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Csv.CsvSheet sheet) -> bool @@ -114,7 +160,6 @@ ExcelReader.Core.Reader.ExcelSheetList ExcelReader.Core.Reader.ExcelSheetList.Count.get -> int ExcelReader.Core.Reader.ExcelSheetList.GetEnumerator() -> System.ArraySegment.Enumerator ExcelReader.Core.Reader.ExcelSheetList.this[int index].get -> TSheet -ExcelReader.Core.Reader.IExcelRowReader.IsDate1904.get -> bool ExcelReader.Core.Reader.IExcelSheet ExcelReader.Core.Reader.IExcelSheet.Index.get -> int ExcelReader.Core.Reader.IExcelSheet.Name.get -> string! @@ -148,18 +193,10 @@ ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.DisposeAsync() -> System.Thre ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask ExcelReader.Core.Reader.Xls.XlsWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xls.XlsSheet -ExcelReader.Core.Reader.Xls.XlsWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! -ExcelReader.Core.Reader.Xls.XlsWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xls.XlsWorkbook.Enumerator! ExcelReader.Core.Reader.Xls.XlsWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xls.XlsWorkbook.MoveToSheet(int index) -> void ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetName.get -> string! -ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xls.XlsWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xls.XlsWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! ExcelReader.Core.Reader.Xls.XlsWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xls.XlsSheet sheet) -> bool -ExcelReader.Core.Reader.Xls.XlsWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool ExcelReader.Core.Reader.Xlsb.XlsbSheet ExcelReader.Core.Reader.Xlsb.XlsbSheet.Equals(ExcelReader.Core.Reader.Xlsb.XlsbSheet other) -> bool ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! @@ -177,18 +214,10 @@ ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.Current.get -> ExcelReader. ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsb.XlsbSheet -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.MoveToSheet(int index) -> void ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetName.get -> string! -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsb.XlsbSheet sheet) -> bool -ExcelReader.Core.Reader.Xlsb.XlsbWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool ExcelReader.Core.Reader.Xlsx.XlsxSheet ExcelReader.Core.Reader.Xlsx.XlsxSheet.Equals(ExcelReader.Core.Reader.Xlsx.XlsxSheet other) -> bool ExcelReader.Core.Reader.Xlsx.XlsxSheet.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! @@ -206,18 +235,10 @@ ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.Current.get -> ExcelReader. ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNext() -> bool ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator.MoveNextAsync() -> System.Threading.Tasks.ValueTask ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.FirstSheet.get -> ExcelReader.Core.Reader.Xlsx.XlsxSheet -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetAsyncEnumerator(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.GetEnumerator() -> ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Enumerator! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.IsDate1904.get -> bool -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.MoveToSheet(int index) -> void ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetCount.get -> int -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetName.get -> string! -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetNameAt(int index) -> string! -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibility.get -> ExcelReader.Core.ExcelSheetVisibility -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.SheetVisibilityAt(int index) -> ExcelReader.Core.ExcelSheetVisibility ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.Sheets.get -> ExcelReader.Core.Reader.ExcelSheetList! ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryGetSheet(System.ReadOnlySpan name, out ExcelReader.Core.Reader.Xlsx.XlsxSheet sheet) -> bool -ExcelReader.Core.Reader.Xlsx.XlsxWorkbook.TryMoveToSheet(System.ReadOnlySpan name) -> bool override ExcelReader.Core.Reader.Csv.CsvSheet.GetHashCode() -> int override ExcelReader.Core.Reader.Xls.XlsSheet.GetHashCode() -> int override ExcelReader.Core.Reader.Xlsb.XlsbSheet.GetHashCode() -> int @@ -241,6 +262,16 @@ static ExcelReader.Core.Reader.Excel.FromXlsxFile(string! path, ExcelReader.Core static ExcelReader.Core.Reader.Excel.FromXlsxFileAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow = 1, int sampleSize = 100, bool parseText = false) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! static ExcelReader.Core.Reader.Excel.InferSchema(ExcelReader.Core.Reader.IExcelSheet! sheet, int headerRow, int sampleSize) -> ExcelReader.Core.Reader.Schema.ExcelColumnSchema[]! +static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(System.ReadOnlyMemory data, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.Open(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null) -> ExcelReader.Core.Reader.IExcelWorkbook! +static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, ExcelReader.Core.Reader.ExcelFileFormat format, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(System.IO.Stream! stream, bool leaveOpen = true, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelFileFormat format, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static ExcelReader.Core.Reader.Excel.OpenAsync(string! path, ExcelReader.Core.Reader.ExcelReaderOptions? options = null, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static ExcelReader.Core.Reader.Xls.XlsSheet.operator !=(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool static ExcelReader.Core.Reader.Xls.XlsSheet.operator ==(ExcelReader.Core.Reader.Xls.XlsSheet left, ExcelReader.Core.Reader.Xls.XlsSheet right) -> bool static ExcelReader.Core.Reader.Xlsb.XlsbSheet.operator !=(ExcelReader.Core.Reader.Xlsb.XlsbSheet left, ExcelReader.Core.Reader.Xlsb.XlsbSheet right) -> bool diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs index 42c16d32..8554a29b 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs @@ -8,12 +8,9 @@ namespace ExcelReader.Core.Reader.Csv { - /// - /// A forward-only reader over a delimited (CSV-style) text source, exposed as a single, unnamed - /// sheet through the same surface as the XLSX/XLSB/XLS readers. - /// + /// An open delimited-text (CSV-style) source, exposed as a workbook with a single, unnamed sheet. /// Unlike the XLSX/XLSB/XLS readers, there are no styles or shared strings to resolve. - public sealed partial class CsvReader : IExcelRowReader, IExcelRowReader + public sealed partial class CsvReader : IExcelWorkbook { [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")] private readonly ByteSource? _source; @@ -156,52 +153,9 @@ private static void ValidateOptions(CsvReaderOptions options) /// public bool IsDate1904 => false; - /// Gets the sheet name. Always the empty string, since a CSV source has a single, unnamed sheet. - public string SheetName => ""; - /// Gets the sheet count. Always 1, since a CSV source has a single, unnamed sheet. public int SheetCount => 1; - /// Gets the sheet name at . Always the empty string, since a CSV source has a single, unnamed sheet. - /// The zero-based sheet index. Must be 0. - /// is not 0. - public string SheetNameAt(int index) - { - ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, SheetCount); - return ""; - } - - /// Gets the sheet's visibility. Always : delimited text has no tab bar to hide from. - public ExcelSheetVisibility SheetVisibility => ExcelSheetVisibility.Visible; - - /// Gets the visibility of the sheet at . Always . - /// The zero-based sheet index. Must be 0. - /// is not 0. - public ExcelSheetVisibility SheetVisibilityAt(int index) - { - ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, SheetCount); - return ExcelSheetVisibility.Visible; - } - - /// Checks whether matches the (empty) CSV sheet name, case-insensitively. - /// The sheet name to look for. - /// if is empty; otherwise . - public bool TryMoveToSheet(ReadOnlySpan name) - { - return name.Equals(SheetName, StringComparison.OrdinalIgnoreCase); - } - - /// Validates that is 0, the only valid sheet index for a CSV source. - /// The zero-based sheet index. Must be 0. - /// is not 0. - public void MoveToSheet(int index) - { - ArgumentOutOfRangeException.ThrowIfNegative(index); - ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, SheetCount); - } - /// Gets the source's sheets: always exactly one, unnamed. Reading the list opens nothing. /// The reader was disposed. public ExcelSheetList Sheets @@ -244,34 +198,6 @@ bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; - /// Gets an enumerator that reads records synchronously from the start of the source. - /// The reader was disposed. - /// The source is a non-seekable stream that was already enumerated. - public Enumerator GetEnumerator() - { - return OpenSheet(); - } - - IExcelRowEnumerator IExcelSheet.GetEnumerator() - { - return GetEnumerator(); - } - - /// Gets an enumerator that reads records asynchronously from the start of the source. - /// A token observed by every MoveNextAsync call. - /// The reader was disposed. - /// The source is a non-seekable stream that was already enumerated. - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - return OpenSheet(ct); - } - - IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) - { - return GetAsyncEnumerator(ct); - } - internal Enumerator OpenSheet(CancellationToken ct = default) { Lifetime.Acquire(this); diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index d5eff04b..df2b26fb 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -16,7 +16,7 @@ namespace ExcelReader.Core.Reader /// /// Entry point for opening Excel and CSV workbooks. / /// and their async counterparts auto-detect XLSX/XLSB/XLS - /// from the file's signature and return a format-agnostic ; the + /// from the file's signature and return a format-agnostic ; the /// From*/FromXls*/FromXlsb*/FromCsv* methods open a specific, known format directly. /// public static partial class Excel @@ -262,58 +262,6 @@ public static async ValueTask FromCsvAsync(Stream stream, bool leaveO return await CsvReader.CreateAsync(stream, leaveOpen, effective, ct).ConfigureAwait(false); } - /// - /// Guesses a column schema for 's current sheet by sampling it from - /// the first row, without disturbing any enumerator the caller already holds. - /// - /// The reader whose current sheet is sampled. - /// 1-based row number to take column names from; 0 means "no header", - /// so every returned schema is addressable only by . - /// How many rows after the header to inspect. - /// One per column, in column order. - /// - /// This is a guess over a bounded sample, not a guarantee about the whole sheet — a column - /// whose first rows are all integers is reported as - /// even if row 10,000 holds text. Verify it fits - /// before trusting it, and feed the result into to - /// build a real map. - /// - /// is . - /// is negative, or - /// is not positive. - /// The sheet has fewer rows than . - public static ExcelColumnSchema[] InferSchema(IExcelRowReader reader, int headerRow, int sampleSize) - { - return InferSchema(reader, headerRow, sampleSize, parseText: false); - } - - /// - /// Guesses a column schema by sampling the reader's rows, as - /// does, and can also type cells that hold text. - /// - /// The reader to sample. Its enumeration is not disturbed. - /// 1-based row number to take column names from; 0 means "no header", - /// so every returned schema is addressable only by . - /// How many rows after the header to inspect. - /// When , a text cell (every CSV field, or a number - /// stored as text) counts as an integer, a decimal, true/false, or an ISO-8601 date or - /// date-time when its text has exactly that shape. Numbers with a leading zero (00123), scientific - /// notation (12E4), padded or culture-formatted numbers, and non-ISO dates stay text. A column only gets a non-text type when - /// every sampled value converts to it. - /// One per column, in column order. - /// Still a guess over a bounded sample: a value past the sample can still fail to convert. - /// See . - /// is . - /// is negative, or - /// is not positive. - /// The sheet has fewer rows than . - public static ExcelColumnSchema[] InferSchema(IExcelRowReader reader, int headerRow = 1, int sampleSize = 100, bool parseText = false) - { - ArgumentNullException.ThrowIfNull(reader); - using IExcelRowEnumerator rows = reader.GetEnumerator(); - return SchemaInference.Infer(rows, reader.IsDate1904, headerRow, sampleSize, parseText); - } - /// Guesses a column schema for by sampling it from the first row. /// The sheet to sample. A new enumerator is opened and disposed; no other read of the sheet is disturbed. /// 1-based row number to take column names from; 0 means "no header", @@ -364,13 +312,13 @@ public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow = /// Opens a workbook from a file path, auto-detecting its format (XLSX/XLSB/XLS) from the file's signature /// and taking ownership of the file stream. /// - /// Pattern-match on the returned reader against its concrete type ( / / ) to access format-specific members. + /// Pattern-match on the returned workbook against its concrete type ( / / ) to access format-specific members. /// The path to the workbook file. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete reader that matches the detected format. /// is . /// The file's signature does not match a supported format. - public static IExcelRowReader Open(string path, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(string path, ExcelReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(path); return OpenSeekable(File.OpenRead(path), leaveOpen: false, options); @@ -383,11 +331,11 @@ public static IExcelRowReader Open(string path, ExcelReaderOptions? options = nu /// A seekable stream containing the workbook data. /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete reader that matches the detected format. /// is . /// does not support seeking. /// The stream's signature does not match a supported format. - public static IExcelRowReader Open(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); return OpenSeekable(stream, leaveOpen, options); @@ -401,9 +349,9 @@ public static IExcelRowReader Open(Stream stream, bool leaveOpen = true, ExcelRe /// /// The whole workbook file's bytes. Must outlive the returned reader. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete reader that matches the detected format. /// The buffer's signature does not match a supported format. - public static IExcelRowReader Open(ReadOnlyMemory data, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(ReadOnlyMemory data, ExcelReaderOptions? options = null) { ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default; if (data.Span.StartsWith(XlsCompoundFile.Signature) && EncryptedPackageOpener.IsEncryptedMemory(data, effective)) @@ -426,7 +374,7 @@ public static IExcelRowReader Open(ReadOnlyMemory data, ExcelReaderOptions }; } - private static IExcelRowReader OpenFromPlainMemory(ReadOnlyMemory plain, ExcelReaderOptions options) + private static IExcelWorkbook OpenFromPlainMemory(ReadOnlyMemory plain, ExcelReaderOptions options) { ExcelFileFormat format = ClassifyMemory(plain, options, out ZipIndex? memZip); if (format is not (ExcelFileFormat.Xlsb or ExcelFileFormat.Xlsx)) @@ -468,10 +416,10 @@ private static ExcelFileFormat ClassifyMemory(ReadOnlyMemory data, ExcelRe /// The path to the workbook file. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete reader that matches the detected format. /// is . /// The file's signature does not match a supported format. - public static ValueTask OpenAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask OpenAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(path); FileStream stream = OpenAsyncFile(path); @@ -486,11 +434,11 @@ public static ValueTask OpenAsync(string path, ExcelReaderOptio /// When (the default), is not disposed when the reader is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete reader that matches the detected format. /// is . /// does not support seeking. /// The stream's signature does not match a supported format. - public static ValueTask OpenAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask OpenAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(stream); return OpenSeekableAsync(stream, leaveOpen, options, ct); @@ -505,13 +453,13 @@ public static ValueTask OpenAsync(Stream stream, bool leaveOpen /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . - /// A format-agnostic . + /// A format-agnostic . /// CSV carries no signature, so auto-detection never reports it; opening delimited text /// means naming here. /// is . /// is or not a defined value. /// is and the file's signature matches no supported format. - public static IExcelRowReader Open(string path, ExcelFileFormat format, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(string path, ExcelFileFormat format, ExcelReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(path); RequireOpenableFormat(format); @@ -536,12 +484,12 @@ public static IExcelRowReader Open(string path, ExcelFileFormat format, ExcelRea /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . - /// A format-agnostic . + /// A format-agnostic . /// is . /// is or not a defined value. /// is and does not support seeking. /// is and the stream's signature matches no supported format. - public static IExcelRowReader Open(Stream stream, ExcelFileFormat format, bool leaveOpen = true, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(Stream stream, ExcelFileFormat format, bool leaveOpen = true, ExcelReaderOptions? options = null) { ArgumentNullException.ThrowIfNull(stream); RequireOpenableFormat(format); @@ -564,10 +512,10 @@ public static IExcelRowReader Open(Stream stream, ExcelFileFormat format, bool l /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . - /// A format-agnostic . + /// A format-agnostic . /// is or not a defined value. /// is and the buffer's signature matches no supported format. - public static IExcelRowReader Open(ReadOnlyMemory data, ExcelFileFormat format, ExcelReaderOptions? options = null) + public static IExcelWorkbook Open(ReadOnlyMemory data, ExcelFileFormat format, ExcelReaderOptions? options = null) { RequireOpenableFormat(format); return format switch @@ -590,20 +538,20 @@ public static IExcelRowReader Open(ReadOnlyMemory data, ExcelFileFormat fo /// . supplies the dialect when /// is . /// A token to cancel the open operation. - /// A format-agnostic . + /// A format-agnostic . /// is . /// is or not a defined value. /// is and the file's signature matches no supported format. - public static ValueTask OpenAsync(string path, ExcelFileFormat format, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask OpenAsync(string path, ExcelFileFormat format, ExcelReaderOptions? options = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(path); RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => AsRowReaderAsync(FromCsvFileAsync(path, CsvOf(options), ct)), - ExcelFileFormat.Xlsx => AsRowReaderAsync(FromXlsxFileAsync(path, options, ct)), - ExcelFileFormat.Xlsb => AsRowReaderAsync(FromXlsbFileAsync(path, options, ct)), - ExcelFileFormat.Xls => AsRowReaderAsync(FromXlsFileAsync(path, options, ct)), + ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvFileAsync(path, CsvOf(options), ct)), + ExcelFileFormat.Xlsx => AsWorkbookAsync(FromXlsxFileAsync(path, options, ct)), + ExcelFileFormat.Xlsb => AsWorkbookAsync(FromXlsbFileAsync(path, options, ct)), + ExcelFileFormat.Xls => AsWorkbookAsync(FromXlsFileAsync(path, options, ct)), _ => OpenAsync(path, options, ct), }; } @@ -620,21 +568,21 @@ public static ValueTask OpenAsync(string path, ExcelFileFormat /// . supplies the dialect when /// is . /// A token to cancel the open operation. - /// A format-agnostic . + /// A format-agnostic . /// is . /// is or not a defined value. /// is and does not support seeking. /// is and the stream's signature matches no supported format. - public static ValueTask OpenAsync(Stream stream, ExcelFileFormat format, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) + public static ValueTask OpenAsync(Stream stream, ExcelFileFormat format, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(stream); RequireOpenableFormat(format); return format switch { - ExcelFileFormat.Csv => AsRowReaderAsync(FromCsvAsync(stream, leaveOpen, CsvOf(options), ct)), - ExcelFileFormat.Xlsx => AsRowReaderAsync(FromXlsxAsync(stream, leaveOpen, options, ct)), - ExcelFileFormat.Xlsb => AsRowReaderAsync(FromXlsbAsync(stream, leaveOpen, options, ct)), - ExcelFileFormat.Xls => AsRowReaderAsync(FromXlsAsync(stream, leaveOpen, options, ct)), + ExcelFileFormat.Csv => AsWorkbookAsync(FromCsvAsync(stream, leaveOpen, CsvOf(options), ct)), + ExcelFileFormat.Xlsx => AsWorkbookAsync(FromXlsxAsync(stream, leaveOpen, options, ct)), + ExcelFileFormat.Xlsb => AsWorkbookAsync(FromXlsbAsync(stream, leaveOpen, options, ct)), + ExcelFileFormat.Xls => AsWorkbookAsync(FromXlsAsync(stream, leaveOpen, options, ct)), _ => OpenAsync(stream, leaveOpen, options, ct), }; } @@ -644,10 +592,10 @@ private static CsvReaderOptions CsvOf(ExcelReaderOptions? options) return options?.Csv ?? CsvReaderOptions.Default; } - private static async ValueTask AsRowReaderAsync(ValueTask opening) - where TReader : IExcelRowReader + private static async ValueTask AsWorkbookAsync(ValueTask pending) + where TWorkbook : IExcelWorkbook { - return await opening.ConfigureAwait(false); + return await pending.ConfigureAwait(false); } private static void RequireOpenableFormat(ExcelFileFormat format) @@ -703,7 +651,7 @@ public static async ValueTask DetectFileFormatAsync(Stream stre return format; } - private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, ExcelReaderOptions? options) + private static IExcelWorkbook OpenSeekable(Stream stream, bool leaveOpen, ExcelReaderOptions? options) { ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default; ExcelFileFormat format; @@ -735,7 +683,7 @@ private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, Excel }; } - private static IExcelRowReader OpenDecryptedZip(Stream decrypted, ExcelReaderOptions options) + private static IExcelWorkbook OpenDecryptedZip(Stream decrypted, ExcelReaderOptions options) { ZipIndex zip = ZipIndex.Create(ByteSource.FromStream(decrypted, leaveOpen: false), options); return ClassifyZip(zip) is ExcelFileFormat.Xlsb @@ -743,7 +691,7 @@ private static IExcelRowReader OpenDecryptedZip(Stream decrypted, ExcelReaderOpt : XlsxWorkbook.CreateFromIndex(zip, options); } - private static async ValueTask OpenSeekableAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options, CancellationToken ct) + private static async ValueTask OpenSeekableAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options, CancellationToken ct) { ExcelReaderOptions effective = options ?? ExcelReaderOptions.Default; ExcelFileFormat format; @@ -776,7 +724,7 @@ private static async ValueTask OpenSeekableAsync(Stream stream, }; } - private static async ValueTask OpenDecryptedZipAsync(Stream decrypted, ExcelReaderOptions options, CancellationToken ct) + private static async ValueTask OpenDecryptedZipAsync(Stream decrypted, ExcelReaderOptions options, CancellationToken ct) { ByteSource source = await ByteSource.FromStreamAsync(decrypted, leaveOpen: false, ct).ConfigureAwait(false); ZipIndex zip = await ZipIndex.CreateAsync(source, options, ct).ConfigureAwait(false); diff --git a/src/ExcelReader.Core/Reader/ExcelDataReader.cs b/src/ExcelReader.Core/Reader/ExcelDataReader.cs index 390c063d..597c043a 100644 --- a/src/ExcelReader.Core/Reader/ExcelDataReader.cs +++ b/src/ExcelReader.Core/Reader/ExcelDataReader.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Reader { /// - /// Adapts the current sheet of an to , so it + /// Adapts one to , so it /// can feed SqlBulkCopy, DataTable.Load, Dapper, or any other ADO.NET consumer directly. /// /// @@ -19,13 +19,12 @@ namespace ExcelReader.Core.Reader /// current row's own cell type; a consumer that builds a schema from the first /// (e.g. DataTable.Load) locks in that row's types for the whole load. /// - /// Exposes a single result set: always returns . Use - /// to walk every sheet and construct one - /// per sheet instead. + /// One reads one sheet: always returns + /// . To read every sheet, construct one per . /// /// - /// Disposing this reader disposes the row enumerator it created, not the - /// passed to the constructor — the caller still owns that. + /// Disposing this reader disposes the row enumerator it created, not the workbook the sheet came + /// from — the caller still owns that. /// /// public sealed class ExcelDataReader : IDataReader @@ -40,26 +39,6 @@ public sealed class ExcelDataReader : IDataReader private bool _rowAvailable; private bool _disposed; - /// - /// Wraps 's current sheet. - /// - /// The sheet to expose. Not disposed by this reader. - /// - /// The 1-based row holding column names. Pass 0 for a header-less sheet, whose columns come back - /// named "Column0", "Column1", ... and whose count is taken from the first data row. - /// - /// is null. - /// is negative. - public ExcelDataReader(IExcelRowReader reader, int headerRow = 1) - { - ArgumentNullException.ThrowIfNull(reader); - ArgumentOutOfRangeException.ThrowIfNegative(headerRow); - _isDate1904 = reader.IsDate1904; - _sheetName = reader.SheetName; - _rows = reader.GetEnumerator(); - (_names, _ordinals, _hasPendingRow, _pendingConsumed) = Initialize(_rows, headerRow); - } - /// /// Wraps . /// @@ -77,35 +56,28 @@ public ExcelDataReader(IExcelSheet sheet, int headerRow = 1) _isDate1904 = sheet.IsDate1904; _sheetName = sheet.Name; _rows = sheet.GetEnumerator(); - (_names, _ordinals, _hasPendingRow, _pendingConsumed) = Initialize(_rows, headerRow); - } - - private static (string?[] Names, Dictionary Ordinals, bool HasPendingRow, bool PendingConsumed) Initialize(IExcelRowEnumerator rows, int headerRow) - { + _pendingConsumed = true; try { - string?[] names; - bool hasPendingRow = false; - bool pendingConsumed = true; - if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(rows, headerRow, out _)) + if (headerRow > 0 && SchemaInference.TrySkipToHeaderRow(_rows, headerRow, out _)) { - names = ReadHeaderNames(rows.Current); + _names = ReadHeaderNames(_rows.Current); } else if (headerRow > 0) { - names = []; + _names = []; } else { - hasPendingRow = rows.MoveNext(); - pendingConsumed = false; - names = hasPendingRow ? new string?[rows.Current.ColumnCount] : []; + _hasPendingRow = _rows.MoveNext(); + _pendingConsumed = false; + _names = _hasPendingRow ? new string?[_rows.Current.ColumnCount] : []; } - return (names, BuildOrdinals(names), hasPendingRow, pendingConsumed); + _ordinals = BuildOrdinals(_names); } catch { - rows.Dispose(); + _rows.Dispose(); throw; } } diff --git a/src/ExcelReader.Core/Reader/ExcelRowReaderExtensions.cs b/src/ExcelReader.Core/Reader/ExcelRowReaderExtensions.cs deleted file mode 100644 index 98723c32..00000000 --- a/src/ExcelReader.Core/Reader/ExcelRowReaderExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace ExcelReader.Core.Reader -{ - /// One sheet's position and name, as yielded by . - /// The sheet's zero-based index. - /// The sheet's name. - public readonly record struct ExcelSheet(int Index, string Name) - { - /// Gets whether the sheet is shown in the workbook's tab bar. - public ExcelSheetVisibility Visibility { get; init; } - } - - /// Convenience methods layered on . - public static class ExcelRowReaderExtensions - { - /// - /// Walks every sheet in 's workbook, selecting each as the current sheet - /// before yielding it. - /// - /// - /// Selecting a sheet moves the reader's one shared cursor (see 's - /// thread-safety remarks), so read that sheet's rows inside the loop body before the next - /// iteration moves on to the following sheet. - /// - /// Hidden sheets are yielded like any other, carrying their ; - /// filter on it to skip them. - /// - /// - /// The workbook reader to walk. - public static IEnumerable Sheets(this IExcelRowReader reader) - { - ArgumentNullException.ThrowIfNull(reader); - return EnumerateSheets(reader); - } - - private static IEnumerable EnumerateSheets(IExcelRowReader reader) - { - for (int i = 0; i < reader.SheetCount; i++) - { - reader.MoveToSheet(i); - yield return new ExcelSheet(i, reader.SheetName) { Visibility = reader.SheetVisibility }; - } - } - } -} diff --git a/src/ExcelReader.Core/Reader/IExcelRowEnumerator.cs b/src/ExcelReader.Core/Reader/IExcelRowEnumerator.cs new file mode 100644 index 00000000..cda5df19 --- /dev/null +++ b/src/ExcelReader.Core/Reader/IExcelRowEnumerator.cs @@ -0,0 +1,21 @@ +namespace ExcelReader.Core.Reader +{ + /// + /// A forward-only cursor over a sheet's rows, implemented by every concrete format's row + /// enumerator and driven either synchronously () or asynchronously + /// (). + /// + public interface IExcelRowEnumerator : IDisposable, IAsyncDisposable + { + /// Gets the row at the enumerator's current position. Only valid after a call to or has returned . + Row Current { get; } + + /// Advances the enumerator to the next row, reading synchronously. + /// if a row was read; if the sheet is exhausted. + bool MoveNext(); + + /// Advances the enumerator to the next row, reading asynchronously. + /// if a row was read; if the sheet is exhausted. + ValueTask MoveNextAsync(); + } +} diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs deleted file mode 100644 index 32c844b8..00000000 --- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace ExcelReader.Core.Reader -{ - /// - /// A workbook reader for the current sheet, typed to the concrete enumerator it hands out - /// () so callers get zero-copy, format-specific row access - /// without boxing to the format-agnostic . - /// - /// The concrete row enumerator type this reader produces. - public interface IExcelRowReader : IExcelSheet - where TEnumerator : IExcelRowEnumerator - { - } - - /// - /// Format-agnostic workbook reader implemented by every concrete reader (XLSX, XLSB, XLS, CSV), - /// exposing row enumeration plus sheet navigation without requiring callers to downcast to the - /// concrete reader type. - /// - /// - /// This is the generic specialized to , - /// plus a sheet-navigation surface and dispose; unifying them lets the typed parser drive a format-agnostic - /// reader () and lets callers walk every sheet without - /// downcasting to the concrete XlsxWorkbook/XlsbWorkbook/XlsWorkbook type. - /// - /// Thread safety: enumerators obtained from one reader may be consumed on different threads, - /// each by one thread at a time, including two enumerators over the same sheet. The reader's own - /// members are not synchronized: select sheets and create enumerators from one thread at a time. A - /// CSV reader over a non-seekable stream serves a single enumerator. Disposing the reader closes it - /// to new enumerators; its file or stream is released when the last enumerator is disposed. - /// - /// - public interface IExcelRowReader : IExcelRowReader, IExcelWorkbook - { - /// Gets a value indicating whether the workbook's date system is 1904-based rather than the default 1900-based system. - new bool IsDate1904 { get; } - - /// Gets the name of the currently selected sheet. - string SheetName { get; } - - /// Gets the name of the sheet at the given zero-based index, without changing the current sheet. - /// The zero-based sheet index. Must be within [0, SheetCount). - string SheetNameAt(int index); - - /// Gets whether the currently selected sheet is shown in the workbook's tab bar. - /// Reported, never enforced: a hidden sheet enumerates its rows like any other, so a caller - /// that wants to skip one — a converter, say — filters on this itself. - ExcelSheetVisibility SheetVisibility { get; } - - /// Gets the visibility of the sheet at the given zero-based index, without changing the current sheet. - /// The zero-based sheet index. Must be within [0, SheetCount). - ExcelSheetVisibility SheetVisibilityAt(int index); - - /// Attempts to select the sheet with the given name (case-insensitive) as the current sheet. - /// The sheet name to look for. - /// if a matching sheet was found and selected; otherwise . - bool TryMoveToSheet(ReadOnlySpan name); - - /// Selects the sheet at the given zero-based index as the current sheet. - /// The zero-based sheet index. Must be within [0, SheetCount). - void MoveToSheet(int index); - } - - /// - /// A forward-only cursor over a sheet's rows, implemented by every concrete format's row - /// enumerator and driven either synchronously () or asynchronously - /// (). - /// - public interface IExcelRowEnumerator : IDisposable, IAsyncDisposable - { - /// Gets the row at the enumerator's current position. Only valid after a call to or has returned . - Row Current { get; } - - /// Advances the enumerator to the next row, reading synchronously. - /// if a row was read; if the sheet is exhausted. - bool MoveNext(); - - /// Advances the enumerator to the next row, reading asynchronously. - /// if a row was read; if the sheet is exhausted. - ValueTask MoveNextAsync(); - } -} diff --git a/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs b/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs index 5b703fc0..34694243 100644 --- a/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs +++ b/src/ExcelReader.Core/Reader/Schema/ExcelColumnSchema.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// One column's guessed shape, as returned by - /// . + /// . /// /// /// This is a guess over a bounded sample, not a guarantee about the whole sheet — verify it fits diff --git a/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs b/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs index 5b995f50..e1af2304 100644 --- a/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs +++ b/src/ExcelReader.Core/Reader/Schema/ExcelColumnType.cs @@ -2,7 +2,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// A column's inferred or declared value type, as produced by - /// . + /// . /// /// /// The underlying values are fixed: they are the XL_T_* constants of the native C ABI diff --git a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs index 228e6108..9c9c607b 100644 --- a/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs +++ b/src/ExcelReader.Core/Reader/Schema/SchemaInference.cs @@ -9,7 +9,7 @@ namespace ExcelReader.Core.Reader.Schema { /// /// Guesses a column schema by sampling a sheet's rows. Backs - /// and the native xl_infer_schema + /// and the native xl_infer_schema /// export, so both answer identically by construction. /// internal static class SchemaInference diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs index 72defb83..009fef8e 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs @@ -6,8 +6,9 @@ namespace ExcelReader.Core.Reader.Xls { - /// Reads rows from a legacy BIFF8 (.xls) workbook, exposing each worksheet through synchronous and asynchronous enumeration. - public sealed partial class XlsWorkbook : IExcelRowReader, IExcelRowReader + /// An open legacy BIFF8 (.xls) workbook. Its sheets are read independently, each through synchronous and asynchronous enumeration. + /// See for the threading and lifetime contract. + public sealed partial class XlsWorkbook : IExcelWorkbook { private readonly WorkbookStream _workbook; private readonly ExcelReaderOptions _options; @@ -18,7 +19,6 @@ public sealed partial class XlsWorkbook : IExcelRowReader, IExcelRowReader _sheetList; - private int _current; internal ReaderLifetime Lifetime { get; } @@ -62,29 +62,9 @@ internal BiffCursor OpenCursor(int offset) return cursor; } - /// - public string SheetName => _sheets[_current].Name; - /// public int SheetCount => _sheets.Length; - /// - public string SheetNameAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - return _sheets[index].Name; - } - - /// - public ExcelSheetVisibility SheetVisibility => _sheets[_current].Visibility; - - /// - public ExcelSheetVisibility SheetVisibilityAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - return _sheets[index].Visibility; - } - /// public bool IsDate1904 => _date1904; @@ -108,24 +88,6 @@ internal bool IsDateStyle(int style) return WorkbookLookups.SharedAt(_sharedOffsets, index); } - /// - public bool TryMoveToSheet(ReadOnlySpan name) - { - if (!WorkbookLookups.TryFindSheetIndex(_sheets, name, static s => s.Name, out int index)) - { - return false; - } - _current = index; - return true; - } - - /// - public void MoveToSheet(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - _current = index; - } - private ExcelSheetList CreateSheetList() { XlsSheet[] sheets = new XlsSheet[_sheets.Length]; @@ -211,29 +173,6 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; - /// - public Enumerator GetEnumerator() - { - return OpenSheet(_current); - } - - IExcelRowEnumerator IExcelSheet.GetEnumerator() - { - return GetEnumerator(); - } - - /// - /// XlsWorkbook is fully in-memory, so nothing is deferred; is checked here and on each move. - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) - { - return OpenSheetAsync(_current, ct); - } - - IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) - { - return GetAsyncEnumerator(ct); - } - /// public void Dispose() { diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index 29863f3b..506de18f 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -6,13 +6,13 @@ namespace ExcelReader.Core.Reader.Xlsb { - /// Reads rows from a binary Excel (.xlsb / BIFF12) workbook, streaming each sheet's cells without loading the whole file into memory. + /// An open binary Excel (.xlsb / BIFF12) workbook. Its sheets are read independently, each streaming its cells without loading the whole file into memory. /// /// Uses the same ZIP/OPC container as .xlsx, but worksheet parts are binary BIFF12 records. The workbook, /// styles, and shared-string parts are read once at open time (they're small); worksheets are streamed on - /// demand by the enumerator. + /// demand by the enumerator. See for the threading and lifetime contract. /// - public sealed partial class XlsbWorkbook : IExcelRowReader, IExcelRowReader + public sealed partial class XlsbWorkbook : IExcelWorkbook { private readonly byte[] _sharedFlat = []; private readonly int[] _sharedOffsets = [0]; @@ -25,7 +25,6 @@ public sealed partial class XlsbWorkbook : IExcelRowReader, IExcelRowReader _sheetList; - private int _current; internal ReaderLifetime Lifetime { get; } internal XlsbWorkbook(byte[] sharedFlat, int[] sharedOffsets, bool[] styleIsDate, bool date1904) @@ -176,42 +175,7 @@ private static (byte[] Flat, int[] Offsets, bool Pooled) LoadSharedStrings( /// public bool IsDate1904 { get; } /// - public string SheetName => _sheets![_current].Name; - /// public int SheetCount => _sheets!.Length; - /// - public string SheetNameAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - return _sheets[index].Name; - } - /// - public ExcelSheetVisibility SheetVisibility => _sheets![_current].Visibility; - /// - public ExcelSheetVisibility SheetVisibilityAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - return _sheets[index].Visibility; - } - - /// - public bool TryMoveToSheet(ReadOnlySpan name) - { - if (!WorkbookLookups.TryFindSheetIndex(_sheets!, name, static s => s.Name, out int index)) - { - return false; - } - _current = index; - return true; - } - - /// - public void MoveToSheet(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets!.Length); - _current = index; - } - internal ReadOnlySpan SharedSpan => _sharedFlat; @@ -318,28 +282,6 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; - /// - public Enumerator GetEnumerator() - { - return OpenSheet(_current); - } - - IExcelRowEnumerator IExcelSheet.GetEnumerator() - { - return GetEnumerator(); - } - - /// - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) - { - return OpenSheetAsync(_current, ct); - } - - IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) - { - return GetAsyncEnumerator(ct); - } - /// public void Dispose() diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index 5e2ca1cd..13a1b87e 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -6,8 +6,9 @@ namespace ExcelReader.Core.Reader.Xlsx { - /// Reads rows from an Office Open XML (.xlsx) workbook, streaming each sheet's cells without loading the whole file into memory. - public sealed partial class XlsxWorkbook : IExcelRowReader, IExcelRowReader + /// An open Office Open XML (.xlsx) workbook. Its sheets are read independently, each streaming its cells without loading the whole file into memory. + /// See for the threading and lifetime contract. + public sealed partial class XlsxWorkbook : IExcelWorkbook { private readonly ZipIndex _zip; private readonly ExcelReaderOptions _options; @@ -15,7 +16,6 @@ public sealed partial class XlsxWorkbook : IExcelRowReader, IExcelRowReader _sheetList; - private int _current; [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed by ReleaseResources, which the lifetime runs after the last enumerator.")] private readonly OnceGate _sharedGate = new(); @@ -114,25 +114,9 @@ internal static async ValueTask CreateFromIndexAsync(ZipIndex zip, } } - /// - public string SheetName => _sheets[_current].Name; /// public int SheetCount => _sheets.Length; /// - public string SheetNameAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - return _sheets[index].Name; - } - /// - public ExcelSheetVisibility SheetVisibility => _sheets[_current].Visibility; - /// - public ExcelSheetVisibility SheetVisibilityAt(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - return _sheets[index].Visibility; - } - /// public bool IsDate1904 { get; } internal bool IsDateStyle(int style) @@ -140,24 +124,6 @@ internal bool IsDateStyle(int style) return WorkbookLookups.IsDateStyle(_styleIsDate, style); } - /// - public bool TryMoveToSheet(ReadOnlySpan name) - { - if (!WorkbookLookups.TryFindSheetIndex(_sheets, name, static s => s.Name, out int index)) - { - return false; - } - _current = index; - return true; - } - - /// - public void MoveToSheet(int index) - { - WorkbookLookups.ValidateSheetIndex(index, _sheets.Length); - _current = index; - } - private ExcelSheetList CreateSheetList() { XlsxSheet[] sheets = new XlsxSheet[_sheets.Length]; @@ -248,28 +214,6 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; - /// - public Enumerator GetEnumerator() - { - return OpenSheet(_current); - } - - IExcelRowEnumerator IExcelSheet.GetEnumerator() - { - return GetEnumerator(); - } - - /// - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) - { - return OpenSheetAsync(_current, ct); - } - - IExcelRowEnumerator IExcelSheet.GetAsyncEnumerator(CancellationToken ct) - { - return GetAsyncEnumerator(ct); - } - internal ReadOnlySpan SharedSpan => _sharedFlat; internal string?[] SharedStringCache => Volatile.Read(ref _sharedStringCache) ?? CreateSharedStringCache(); diff --git a/tests/ExcelReader.Tests/Reader/ExcelRowReaderExtensionsTests.cs b/tests/ExcelReader.Tests/Reader/ExcelRowReaderExtensionsTests.cs deleted file mode 100644 index cfcbf411..00000000 --- a/tests/ExcelReader.Tests/Reader/ExcelRowReaderExtensionsTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ExcelReader.Core.Reader; - -namespace ExcelReader.Tests.Reader -{ - public class ExcelRowReaderExtensionsTests - { - [Fact] - public async Task SheetsYieldsEveryIndexAndNameInOrder() - { - await using var ms = await TypedWorkbook.BuildMultiSheetAsync( - ("Alpha", [[1]]), - ("Beta", [[2]]), - ("Gamma", [[3]])); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - - List sheets = [.. reader.Sheets()]; - - Assert.Equal( - [new ExcelSheet(0, "Alpha"), new ExcelSheet(1, "Beta"), new ExcelSheet(2, "Gamma")], - sheets); - } - - [Fact] - public async Task SheetsSelectsEachSheetBeforeYieldingItSoRowsAreReadable() - { - await using var ms = await TypedWorkbook.BuildMultiSheetAsync( - ("A", [[11]]), - ("B", [[22]])); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - - var firstCells = new List(); - foreach (ExcelSheet sheet in reader.Sheets()) - { - await using var e = reader.GetEnumerator(); - Assert.True(await e.MoveNextAsync()); - Assert.True(e.Current[0].TryParse(null, out int v)); - firstCells.Add(v); - } - - Assert.Equal([11, 22], firstCells); - } - - [Fact] - public async Task SheetsOnASingleSheetWorkbookYieldsOneEntry() - { - await using var ms = await TypedWorkbook.BuildAsync([1]); - await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); - - ExcelSheet[] sheets = [.. reader.Sheets()]; - - Assert.Single(sheets); - Assert.Equal(0, sheets[0].Index); - } - - [Fact] - public void SheetsThrowsOnNullReader() - { - IExcelRowReader reader = null!; - Assert.Throws(() => reader.Sheets()); - } - } -} From b18263b9fbe837c12ef5e72e0eb31e1eeaded1fa Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 18:01:33 -0300 Subject: [PATCH 13/49] docs(core): carry the removed overloads' documentation to the sheet overloads Co-Authored-By: Claude Opus 5.5 --- .../ArrowConversionExtensions.cs | 10 ++++- src/ExcelReader.Core/Reader/Excel.cs | 43 +++++++++++-------- src/ExcelReader.Core/Reader/Xls/XlsSheet.cs | 5 ++- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs index 4c311277..a06e89be 100644 --- a/src/ExcelReader.Arrow/ArrowConversionExtensions.cs +++ b/src/ExcelReader.Arrow/ArrowConversionExtensions.cs @@ -10,9 +10,17 @@ public static class ArrowConversionExtensions /// Converts one sheet into an Arrow . /// The sheet to convert. /// The column schema to use; inferred from the sheet with when . - /// 1-based row number holding the column names. + /// + /// 1-based header row, reused both for schema inference (when is + /// ) and to skip the header line during the data pass. 0 means no header row. + /// /// A record batch with one column per schema entry and one row per data row. /// is . + /// + /// A cell failed to convert to its column's declared type on a non-nullable column. + /// + /// The sheet has fewer rows than . + /// names an unrecognized . public static RecordBatch ToArrowRecordBatch(this IExcelSheet sheet, ExcelColumnSchema[]? schema = null, int headerRow = 1) { ArgumentNullException.ThrowIfNull(sheet); diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index df2b26fb..670fda03 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -31,7 +31,7 @@ public static XlsxWorkbook FromXlsxFile(string path, ExcelReaderOptions? options /// Opens an XLSX workbook from an existing stream. /// The stream containing the XLSX data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . public static XlsxWorkbook FromXlsx(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -71,7 +71,7 @@ public static XlsWorkbook FromXlsFile(string path, ExcelReaderOptions? options = /// Opens a legacy binary (XLS) workbook from an existing stream. /// The stream containing the XLS data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . public static XlsWorkbook FromXls(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -96,7 +96,7 @@ public static XlsbWorkbook FromXlsbFile(string path, ExcelReaderOptions? options /// Opens an XLSB (Excel binary) workbook from an existing stream. /// The stream containing the XLSB data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . public static XlsbWorkbook FromXlsb(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -138,7 +138,7 @@ public static ValueTask FromXlsxFileAsync(string path, ExcelReader /// Asynchronously opens an XLSX workbook from an existing stream. /// The stream containing the XLSX data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsxAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -162,7 +162,7 @@ public static ValueTask FromXlsFileAsync(string path, ExcelReaderOp /// Asynchronously opens a legacy binary (XLS) workbook from an existing stream. /// The stream containing the XLS data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -182,7 +182,7 @@ public static ValueTask FromXlsbFileAsync(string path, ExcelReader /// Asynchronously opens an XLSB (Excel binary) workbook from an existing stream. /// The stream containing the XLSB data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsbAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -213,7 +213,7 @@ public static CsvReader FromCsvFile(string path, CsvReaderOptions? options = nul /// Opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Delimiter, quote, encoding, and size-limit settings; when . public static CsvReader FromCsv(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null) { @@ -252,7 +252,7 @@ public static async ValueTask FromCsvFileAsync(string path, CsvReader /// Asynchronously opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Delimiter, quote, encoding, and size-limit settings; when . /// A token to cancel the open operation. public static async ValueTask FromCsvAsync(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null, CancellationToken ct = default) @@ -269,8 +269,11 @@ public static async ValueTask FromCsvAsync(Stream stream, bool leaveO /// How many rows after the header to inspect. /// One per column, in column order. /// - /// This is a guess over a bounded sample, not a guarantee about the whole sheet. Verify it fits - /// before trusting it, and feed the result into to build a real map. + /// This is a guess over a bounded sample, not a guarantee about the whole sheet — a column + /// whose first rows are all integers is reported as + /// even if row 10,000 holds text. Verify it fits + /// before trusting it, and feed the result into to + /// build a real map. /// /// is . /// is negative, or @@ -295,6 +298,8 @@ public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow, /// notation (12E4), padded or culture-formatted numbers, and non-ISO dates stay text. A column only gets a non-text type when /// every sampled value converts to it. /// One per column, in column order. + /// Still a guess over a bounded sample: a value past the sample can still fail to convert. + /// See . /// is . /// is negative, or /// is not positive. @@ -315,7 +320,7 @@ public static ExcelColumnSchema[] InferSchema(IExcelSheet sheet, int headerRow = /// Pattern-match on the returned workbook against its concrete type ( / / ) to access format-specific members. /// The path to the workbook file. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete workbook that matches the detected format. /// is . /// The file's signature does not match a supported format. public static IExcelWorkbook Open(string path, ExcelReaderOptions? options = null) @@ -329,9 +334,9 @@ public static IExcelWorkbook Open(string path, ExcelReaderOptions? options = nul /// stream's signature. /// /// A seekable stream containing the workbook data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete workbook that matches the detected format. /// is . /// does not support seeking. /// The stream's signature does not match a supported format. @@ -349,7 +354,7 @@ public static IExcelWorkbook Open(Stream stream, bool leaveOpen = true, ExcelRea /// /// The whole workbook file's bytes. Must outlive the returned reader. /// Resource limits and behavior toggles; when . - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete workbook that matches the detected format. /// The buffer's signature does not match a supported format. public static IExcelWorkbook Open(ReadOnlyMemory data, ExcelReaderOptions? options = null) { @@ -416,7 +421,7 @@ private static ExcelFileFormat ClassifyMemory(ReadOnlyMemory data, ExcelRe /// The path to the workbook file. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete workbook that matches the detected format. /// is . /// The file's signature does not match a supported format. public static ValueTask OpenAsync(string path, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -431,10 +436,10 @@ public static ValueTask OpenAsync(string path, ExcelReaderOption /// (XLSX/XLSB/XLS) from the stream's signature. /// /// A seekable stream containing the workbook data. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. - /// A format-agnostic backed by the concrete reader that matches the detected format. + /// A format-agnostic backed by the concrete workbook that matches the detected format. /// is . /// does not support seeking. /// The stream's signature does not match a supported format. @@ -480,7 +485,7 @@ public static IExcelWorkbook Open(string path, ExcelFileFormat format, ExcelRead /// is . /// The format to read as. /// auto-detects XLSX/XLSB/XLS from the signature. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . @@ -563,7 +568,7 @@ public static ValueTask OpenAsync(string path, ExcelFileFormat f /// is . /// The format to read as. /// auto-detects XLSX/XLSB/XLS from the signature. - /// When (the default), is not disposed when the reader is disposed. + /// When (the default), is not disposed when the workbook is disposed. /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . diff --git a/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs index dde2edba..8b2ed0a4 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs @@ -34,7 +34,10 @@ public XlsWorkbook.Enumerator GetEnumerator() return Workbook.OpenSheet(Index); } - /// + /// Gets a new enumerator that reads the sheet's rows asynchronously from the start. + /// XlsWorkbook is fully in-memory, so nothing is deferred; is checked here and on each move. + /// A token checked by this call and by every call. + /// The sheet's workbook was disposed. public XlsWorkbook.Enumerator GetAsyncEnumerator(CancellationToken ct = default) { return Workbook.OpenSheetAsync(Index, ct); From 262933311297763a2f4e64f09767bda9984dfa16 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 18:07:16 -0300 Subject: [PATCH 14/49] test(core): open and read sheets concurrently across every source kind The concurrency theories now run over memory, path, FileStream, a seekable stream and a non-seekable stream. A new theory obtains sheets by index and by name and opens their enumerators from twelve threads at once, racing the first shared-string load. A fact checks that a disposed workbook refuses a new enumerator while a live one reads to the end. Co-Authored-By: Claude Opus 5.5 --- .../Reader/ConcurrentSheetTests.cs | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs index e7507436..4b96470e 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs @@ -19,8 +19,10 @@ public enum Format public enum Source { Memory, - Stream, Path, + FileStream, + SeekableStream, + NonSeekableStream, } private const int Sheets = 6; @@ -101,18 +103,32 @@ private static byte[] Build(Format format) private IExcelWorkbook Open(Format format, Source source, byte[] bytes) { - if (source == Source.Memory) + switch (source) { - return Excel.Open(bytes); + case Source.Memory: + return Excel.Open(bytes); + case Source.SeekableStream: + return Excel.Open(new TrickleStream(bytes), leaveOpen: false); + case Source.NonSeekableStream: + return OpenNonSeekable(format, new NonSeekableStream(bytes)); + default: + string path = Path.Combine(Path.GetTempPath(), "excelreader-concurrent-" + Guid.NewGuid().ToString("N") + "." + format.ToString()); + File.WriteAllBytes(path, bytes); + _paths.Add(path); + return source == Source.Path + ? Excel.Open(path) + : Excel.Open(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read), leaveOpen: false); } - if (source == Source.Stream) + } + + private static IExcelWorkbook OpenNonSeekable(Format format, Stream stream) + { + return format switch { - return Excel.Open(new MemoryStream(bytes, writable: false), leaveOpen: false); - } - string path = Path.Combine(Path.GetTempPath(), "excelreader-concurrent-" + Guid.NewGuid().ToString("N") + "." + format.ToString()); - File.WriteAllBytes(path, bytes); - _paths.Add(path); - return Excel.Open(path); + Format.Xlsx => Excel.FromXlsx(stream, leaveOpen: false), + Format.Xlsb => Excel.FromXlsb(stream, leaveOpen: false), + _ => Excel.FromXls(stream, leaveOpen: false), + }; } private static List Drain(IExcelRowEnumerator e) @@ -261,5 +277,55 @@ public void Threads_Reading_One_Sheet_Get_The_Same_String_Instances(Format forma } } } + + [Theory] + [MemberData(nameof(Cases))] + public void Sheets_Are_Obtained_And_Opened_From_Many_Threads_At_Once(Format format, Source source) + { + byte[] bytes = Build(format); + List[] expected; + using (IExcelWorkbook reference = Excel.Open(bytes)) + { + expected = ReadSequentially(reference); + } + + using IExcelWorkbook workbook = Open(format, source, bytes); + const int Workers = 12; + List[] actual = new List[Workers]; + Parallel.For(0, Workers, new ParallelOptions { MaxDegreeOfParallelism = Workers }, w => + { + int s = w % workbook.SheetCount; + Assert.Equal(Sheets, workbook.SheetCount); + Assert.True(workbook.TryGetSheet("SHEET" + s.ToString(CultureInfo.InvariantCulture), out IExcelSheet? byName)); + Assert.Equal(s, byName.Index); + using IExcelRowEnumerator e = workbook.SheetAt(s).GetEnumerator(); + actual[w] = Drain(e); + }); + + for (int w = 0; w < Workers; w++) + { + Assert.Equal(expected[w % Sheets], actual[w], StringComparer.Ordinal); + } + } + + [Fact] + public void A_Disposed_Workbook_Refuses_New_Enumerators_While_A_Live_One_Reads_To_The_End() + { + byte[] bytes = Build(Format.Xlsx); + IExcelWorkbook workbook = Excel.Open(bytes); + IExcelSheet sheet = workbook.FirstSheet; + using IExcelRowEnumerator live = sheet.GetEnumerator(); + Assert.True(live.MoveNext()); + + workbook.Dispose(); + + Assert.Throws(() => sheet.GetEnumerator()); + int rows = 1; + while (live.MoveNext()) + { + rows++; + } + Assert.Equal(Rows, rows); + } } } From a761fc4f511b6e5efedbdbdcfb9844bd2bedce22 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 18:14:12 -0300 Subject: [PATCH 15/49] docs: describe workbooks and sheets in the README and the architecture notes The quick start, the feature notes and the reader table now use the v6 workbook and sheet types, ARCHITECTURE.md gains a paragraph on the workbook, sheet and enumerator split and the reference-counted lifetime, and the style guide example names a member that still exists. Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 52 ++++++++++++++++++++++++++++++------------------- README.md | 10 ++++++---- STYLEGUIDE.md | 6 +++--- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5479c3ec..f1157922 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,31 +10,42 @@ memory-footprint measurements, approaches that were tried and rejected. That liv ## The four format families -Each format has its own `Reader` and a writer implementing `IWorkbookWriter` +Each format has its own workbook (`CsvReader` for CSV) and a writer implementing `IWorkbookWriter` (`src/ExcelReader.Core/Writer/IWorkbookWriter.cs`): -| Format | Reader | Writer | Sheet/row writer | +| Format | Workbook | Writer | Sheet/row writer | |---|---|---|---| -| XLSX | `XlsxReader` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` | -| XLSB | `XlsbReader` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` | -| XLS | `XlsReader` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` | +| XLSX | `XlsxWorkbook` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` | +| XLSB | `XlsbWorkbook` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` | +| XLS | `XlsWorkbook` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` | | CSV | `CsvReader` | `CsvWorkbookWriter` | `CsvSheetWriter`/`CsvRowWriter` | CSV has one extra layer: `CsvWriter` is the low-level RFC4180 writer (buffered rows straight to the stream, no sheets/styles/shared-strings machinery); `CsvWorkbookWriter` adapts it to the shared `IWorkbookWriter` contract, exposing exactly one sheet. -All four open through `Excel.Open`/`OpenAsync`, which take an optional `ExcelFileFormat`. The three -signed formats are detected from the file's first bytes; CSV carries no signature, so it is named +All four open through `Excel.Open`/`OpenAsync`, which return an `IExcelWorkbook` and take an optional +`ExcelFileFormat`. The three signed formats are detected from the file's first bytes; CSV carries no signature, so it is named rather than detected, and its dialect rides along in `ExcelReaderOptions.Csv`. That one options object is what the native ABI's `xl_open_options` maps onto, so `ReadApi.Open` dispatches formats rather than reimplementing them. -On top of all four readers sits the typed-parsing layer (`src/ExcelReader.Core/Parser/`): +A workbook, a sheet and an enumerator are three separate things. The workbook (`XlsxWorkbook` and its +siblings, `IExcelWorkbook`) owns the shared state: the file or stream, the sheet list, the shared +strings and styles. A sheet (`XlsxSheet` and its siblings, `IExcelSheet`) is a `readonly struct` of +workbook plus index; it holds no resources, and `workbook.Sheets`, `FirstSheet`, `SheetAt` and +`TryGetSheet` hand them out without opening anything. Iterating a sheet creates an enumerator +(`XlsxWorkbook.Enumerator`), the one place that streams rows and the only mutable part, used by one +thread at a time. Lifetime is reference counted (`ReaderLifetime`): the workbook holds one reference +and each live enumerator another, so disposing the workbook closes it to new sheets and enumerators but +releases the file, stream and pooled buffers only when the last enumerator is disposed. Shared state is +published lazily and race-safely, which is what lets sheets of one workbook be read in parallel. + +On top of all four workbooks sits the typed-parsing layer (`src/ExcelReader.Core/Parser/`): `ExcelParser`, built by `ExcelParser.FromAttributes` (reflection), `ExcelParser.Generated` (the source-generated map) or `ExcelParser.Build` (a runtime-configured map). Its model may be a class, a struct or a `ref struct`; a `ref struct` model binds directly to `Cell.Value` spans — zero allocation for the container and, -for span-typed columns, for the values too. It consumes `Row`/`Cell` from any reader uniformly. +for span-typed columns, for the values too. It consumes `Row`/`Cell` from any sheet uniformly. ## Parallel CSV parsing @@ -166,7 +177,7 @@ turns one back into a stream the ordinary readers consume: being materialized. One 4 KiB segment is cached, which is enough because ZIP reads are sequential within an entry. -`XlsxReader` and `XlsbReader` are untouched by any of this: they receive a stream that happens to +`XlsxWorkbook` and `XlsbWorkbook` are untouched by any of this: they receive a stream that happens to decrypt. Writing encrypted workbooks is a separate step, `Excel.EncryptPackage`/`EncryptPackageAsync` in `PackageEncryptor.cs` — it wraps a plaintext package (written by any of the ordinary writers) in an agile-encrypted CFB container, the inverse of `DecryptedPackageStream`. There is still no @@ -197,28 +208,29 @@ failure in red through `ErrorConsole` on a terminal, or passes it through byte-f Both live in `ExcelReader.Cli`, not `CliCommands.cs` - the interactive/plain decision is `Console`-shaped state, exactly what that file's tests are built to never touch. -## Why readers are split into partial classes +## Why workbooks are split into partial classes -`XlsxReader` and `XlsbReader` are large enough that one file would be unwieldy, so each is split by +`XlsxWorkbook` and `XlsbWorkbook` are large enough that one file would be unwieldy, so each is split by concern rather than by size: -- `XlsxReader.cs` / `XlsbReader.cs` — fields, constructors, sheet navigation, dispose. +- `XlsxWorkbook.cs` / `XlsbWorkbook.cs` — fields, constructors, the sheet list, dispose. - `*.Loading.cs` (XLSX only) — one-time workbook-level XML parsing (sheets, shared strings, date1904). -- `*.Memory.cs` — the in-memory path: constructs directly over `ZipMemoryIndex`/`ZipPart` instead of - a `Stream`/`ZipArchive`, so it never suspends even under `await foreach`. +- `XlsxWorkbook.Memory.cs` (XLSX only) — the shared-string parse over an already-decompressed in-memory + part, so it never suspends even under `await foreach`. - `*.Styles.cs` (XLSX only) — builds the cellXfs-index → is-date-style table. - `*.Enumerator.cs` — the nested `Enumerator`: the actual streaming row/cell parser. By far the - largest file in each reader. + largest file in each workbook. -All partials of one reader share one field set (C# partial classes are one type), so e.g. +All partials of one workbook share one field set (C# partial classes are one type), so e.g. `.Loading.cs`'s shared-string parse populates fields the nested `Enumerator` in `.Enumerator.cs` -reads back. `XlsReader` follows a reduced version of the same split (no `.Memory.cs` — the OLE -compound-file container has no in-memory-ZIP equivalent). +reads back. `XlsWorkbook` follows a reduced version of the same split (`.Strings.cs` for its string +tables, no `.Loading.cs`/`.Memory.cs`). The sheet values live in their own small files +(`XlsxSheet.cs`, `XlsbSheet.cs`, `XlsSheet.cs`, `CsvSheet.cs`). ## The sync/async twin convention Hot-path search/refill primitives (e.g. `IndexOf`/`IndexOfAsync`/`IndexOfSlowAsync` in -`XlsxReader.Enumerator.cs`) come in three tiers, not one generic async method: +`XlsxWorkbook.Enumerator.cs`) come in three tiers, not one generic async method: 1. A blocking sync loop for the sync caller. 2. An async method whose common case — the data is already in the buffered window — is a synchronous diff --git a/README.md b/README.md index 6312b8bf..ec25938a 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ dotnet add package ExcelReader.NET ```csharp using ExcelReader.Core.Reader; -using IExcelRowReader reader = Excel.Open("book.xlsx"); -foreach (Row row in reader) +using IExcelWorkbook workbook = Excel.Open("book.xlsx"); +foreach (Row row in workbook.FirstSheet) { foreach (RowCell cell in row.Cells) { @@ -40,6 +40,7 @@ foreach (Row row in reader) - [Writing workbooks](docs/guide/writing.md) — XLSX, XLSB (BIFF12) and XLS (BIFF8) writers, cell styles, typed records, and prefetch compression. - [CSV](docs/guide/csv.md) — reading, dialect sniffing, parallel parsing, and writing delimited text. - [Encrypted workbooks](docs/guide/encryption.md) — opening password-protected packages and encrypting written ones. +- [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/). ## Command line @@ -73,8 +74,9 @@ errors to stderr, so `convert` is safe to pipe. - Reads `.xlsx`, `.xlsb` (BIFF12), `.xls` (BIFF8), and `.csv`; writes `.xlsx`, `.xlsb`, `.xls`, and `.csv`. - `Excel.Open`/`OpenAsync` auto-detect XLSX/XLSB/XLS from the file signature. CSV has none, so pass `ExcelFileFormat.Csv` to open delimited text through the same entry point; `ExcelReaderOptions.Csv` carries the dialect, and `CsvReaderOptions.SniffDialect` infers it from the source. -- Reads one sheet at a time (XLSX/XLSB/XLS); use `MoveToSheet(index)` or `TryMoveToSheet(name)` to switch sheets. CSV has no sheets. -- `SheetVisibility`/`SheetVisibilityAt(index)` report whether a sheet is hidden (`Visible`, `Hidden`, `VeryHidden`). Hidden sheets still enumerate their rows — filter on the value if you want them skipped. The writers take the same value via `AddSheet(name, visibility)`, and reject a workbook whose sheets would all be hidden. +- A workbook hands out its sheets: `FirstSheet`, `Sheets[index]` or `SheetAt(index)`, and `TryGetSheet(name)`. CSV is a workbook with one unnamed sheet. Coming from v5? See [Migrating to v6](docs/guide/migrating-to-v6.md). +- Sheets of one workbook can be read in parallel, one enumerator per thread. +- `sheet.Visibility` reports whether a sheet is hidden (`Visible`, `Hidden`, `VeryHidden`). Hidden sheets still enumerate their rows — filter on the value if you want them skipped. The writers take the same value via `AddSheet(name, visibility)`, and reject a workbook whose sheets would all be hidden. - Missing cells in sparse rows are exposed as empty cells. - String conversion allocates only when you call `GetString()`. - The XLSX scanner accepts the SpreadsheetML shapes commonly emitted by non-Excel producers, including single-quoted attributes, comments in `sheetData`, and CDATA text runs. diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index c6ae8c6f..7cb2cb22 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -97,15 +97,15 @@ Every method, property getter/setter, and constructor must use a block body with ```csharp // NOT OK -public string SheetName => _sheets[_current].Name; +public int SheetCount => _sheets.Length; private static string Foo() => Bar(); // OK -public string SheetName +public int SheetCount { get { - return _sheets[_current].Name; + return _sheets.Length; } } private static string Foo() From ee86bf657102aeccfe7d71de0d973404fd187ded Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 18:19:58 -0300 Subject: [PATCH 16/49] docs: tidy the architecture notes Re-wrap the reflowed open paragraph, align the reader table and list XlsbWorkbookParts.cs in the partial-class layout. Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f1157922..2d7c67de 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,25 +10,25 @@ memory-footprint measurements, approaches that were tried and rejected. That liv ## The four format families -Each format has its own workbook (`CsvReader` for CSV) and a writer implementing `IWorkbookWriter` -(`src/ExcelReader.Core/Writer/IWorkbookWriter.cs`): +Each format has its own workbook (`CsvReader` for CSV) and a writer implementing +`IWorkbookWriter` (`src/ExcelReader.Core/Writer/IWorkbookWriter.cs`): | Format | Workbook | Writer | Sheet/row writer | |---|---|---|---| | XLSX | `XlsxWorkbook` | `XlsxWorkbookWriter` | `XlsxSheetWriter`/`XlsxRowWriter` | | XLSB | `XlsbWorkbook` | `XlsbWorkbookWriter` | `XlsbSheetWriter`/`XlsbRowWriter` | -| XLS | `XlsWorkbook` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` | -| CSV | `CsvReader` | `CsvWorkbookWriter` | `CsvSheetWriter`/`CsvRowWriter` | +| XLS | `XlsWorkbook` | `XlsWorkbookWriter` | `XlsSheetWriter`/`XlsRowWriter` | +| CSV | `CsvReader` | `CsvWorkbookWriter` | `CsvSheetWriter`/`CsvRowWriter` | CSV has one extra layer: `CsvWriter` is the low-level RFC4180 writer (buffered rows straight to the stream, no sheets/styles/shared-strings machinery); `CsvWorkbookWriter` adapts it to the shared `IWorkbookWriter` contract, exposing exactly one sheet. -All four open through `Excel.Open`/`OpenAsync`, which return an `IExcelWorkbook` and take an optional -`ExcelFileFormat`. The three signed formats are detected from the file's first bytes; CSV carries no signature, so it is named -rather than detected, and its dialect rides along in `ExcelReaderOptions.Csv`. That one options -object is what the native ABI's `xl_open_options` maps onto, so `ReadApi.Open` dispatches formats -rather than reimplementing them. +All four open through `Excel.Open`/`OpenAsync`, which return an `IExcelWorkbook` and take an +optional `ExcelFileFormat`. The three signed formats are detected from the file's first bytes; CSV +carries no signature, so it is named rather than detected, and its dialect rides along in +`ExcelReaderOptions.Csv`. That one options object is what the native ABI's `xl_open_options` maps +onto, so `ReadApi.Open` dispatches formats rather than reimplementing them. A workbook, a sheet and an enumerator are three separate things. The workbook (`XlsxWorkbook` and its siblings, `IExcelWorkbook`) owns the shared state: the file or stream, the sheet list, the shared @@ -214,6 +214,7 @@ Both live in `ExcelReader.Cli`, not `CliCommands.cs` - the interactive/plain dec concern rather than by size: - `XlsxWorkbook.cs` / `XlsbWorkbook.cs` — fields, constructors, the sheet list, dispose. +- `XlsbWorkbookParts.cs` (XLSB only) — parses `workbook.bin` for the sheet names, paths and visibility. - `*.Loading.cs` (XLSX only) — one-time workbook-level XML parsing (sheets, shared strings, date1904). - `XlsxWorkbook.Memory.cs` (XLSX only) — the shared-string parse over an already-decompressed in-memory part, so it never suspends even under `await foreach`. From b0f956ad5612d432d10b0c749af56d91033bf323 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 18:22:41 -0300 Subject: [PATCH 17/49] test(core): read content from an enumerator that outlives its workbook The dispose-while-live check now drains the live enumerator and compares every cell, shared strings included, with a sequential read, and runs over memory, FileStream and a seekable stream, where an early release of the zip or the shared table would break the read. Co-Authored-By: Claude Opus 5.5 --- .../Reader/ConcurrentSheetTests.cs | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs index 4b96470e..91e0638f 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs @@ -308,24 +308,37 @@ public void Sheets_Are_Obtained_And_Opened_From_Many_Threads_At_Once(Format form } } - [Fact] - public void A_Disposed_Workbook_Refuses_New_Enumerators_While_A_Live_One_Reads_To_The_End() + [Theory] + [InlineData(Source.Memory)] + [InlineData(Source.FileStream)] + [InlineData(Source.SeekableStream)] + public void A_Disposed_Workbook_Refuses_New_Enumerators_While_A_Live_One_Reads_To_The_End(Source source) { byte[] bytes = Build(Format.Xlsx); - IExcelWorkbook workbook = Excel.Open(bytes); - IExcelSheet sheet = workbook.FirstSheet; - using IExcelRowEnumerator live = sheet.GetEnumerator(); - Assert.True(live.MoveNext()); + List[] expected; + using (IExcelWorkbook reference = Excel.Open(bytes)) + { + expected = ReadSequentially(reference); + } - workbook.Dispose(); + IExcelWorkbook workbook = Open(Format.Xlsx, source, bytes); + IExcelSheet sheet; + IExcelRowEnumerator live; + try + { + sheet = workbook.FirstSheet; + live = sheet.GetEnumerator(); + } + finally + { + workbook.Dispose(); + } - Assert.Throws(() => sheet.GetEnumerator()); - int rows = 1; - while (live.MoveNext()) + using (live) { - rows++; + Assert.Throws(() => sheet.GetEnumerator()); + Assert.Equal(expected[0], Drain(live), StringComparer.Ordinal); } - Assert.Equal(Rows, rows); } } } From fbce601e0efc1af25eae7959d03cb88fa170ea3b Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 19:27:16 -0300 Subject: [PATCH 18/49] docs(core): say a source must outlive every enumerator, not only the workbook Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Core/Reader/Csv/CsvReader.cs | 16 ++++++- src/ExcelReader.Core/Reader/Csv/CsvSheet.cs | 40 +++++++++++++++--- src/ExcelReader.Core/Reader/Excel.cs | 42 +++++++++---------- src/ExcelReader.Core/Reader/ExcelSheetList.cs | 8 +++- src/ExcelReader.Core/Reader/IExcelWorkbook.cs | 10 +++-- src/ExcelReader.Core/Reader/Xls/XlsSheet.cs | 8 +++- .../Reader/Xls/XlsWorkbook.cs | 16 ++++++- src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs | 8 +++- .../Reader/Xlsb/XlsbWorkbook.cs | 16 ++++++- src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs | 8 +++- .../Reader/Xlsx/XlsxWorkbook.cs | 16 ++++++- src/ExcelReader.Native/NativeHandle.cs | 8 +++- .../Native/SessionLifetimeTests.cs | 2 +- 13 files changed, 155 insertions(+), 43 deletions(-) diff --git a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs index 8554a29b..5ef80d2e 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvReader.cs @@ -194,9 +194,21 @@ bool IExcelWorkbook.TryGetSheet(ReadOnlySpan name, [MaybeNullWhen(false)] /// Gets the source's only sheet. /// The workbook was disposed. - public CsvSheet FirstSheet => Sheets[0]; + public CsvSheet FirstSheet + { + get + { + return Sheets[0]; + } + } - IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + IExcelSheet IExcelWorkbook.FirstSheet + { + get + { + return FirstSheet; + } + } internal Enumerator OpenSheet(CancellationToken ct = default) { diff --git a/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs index aea13d07..39015bc7 100644 --- a/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs +++ b/src/ExcelReader.Core/Reader/Csv/CsvSheet.cs @@ -11,18 +11,48 @@ internal CsvSheet(CsvReader reader) } /// - public int Index => 0; + public int Index + { + get + { + return 0; + } + } /// Gets the sheet name. Always the empty string, since a CSV source has a single, unnamed sheet. - public string Name => ""; + public string Name + { + get + { + return ""; + } + } /// Gets the sheet's visibility. Always : delimited text has no tab bar to hide from. - public ExcelSheetVisibility Visibility => ExcelSheetVisibility.Visible; + public ExcelSheetVisibility Visibility + { + get + { + return ExcelSheetVisibility.Visible; + } + } /// - public bool IsDate1904 => false; + public bool IsDate1904 + { + get + { + return false; + } + } - internal CsvReader Reader => _reader ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + private CsvReader Reader + { + get + { + return _reader ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + } + } /// /// The source is a non-seekable stream that was already enumerated. diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index 670fda03..dccf0847 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -31,7 +31,7 @@ public static XlsxWorkbook FromXlsxFile(string path, ExcelReaderOptions? options /// Opens an XLSX workbook from an existing stream. /// The stream containing the XLSX data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . public static XlsxWorkbook FromXlsx(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -46,9 +46,9 @@ public static XlsxWorkbook FromXlsx(Stream stream, bool leaveOpen = true, ExcelR /// Opens an XLSX workbook directly from an in-memory buffer. Reads the ZIP /// central directory and decompresses parts without a ZipArchive /// or intermediate — every part is fully materialized up front, so the returned - /// reader never suspends, even under await foreach. + /// workbook never suspends, even under await foreach. /// - /// The whole XLSX file's bytes. Must outlive the returned reader. + /// The whole XLSX file's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . is ignored on this path — there is nothing left to overlap. public static XlsxWorkbook FromXlsx(ReadOnlyMemory data, ExcelReaderOptions? options = null) { @@ -71,7 +71,7 @@ public static XlsWorkbook FromXlsFile(string path, ExcelReaderOptions? options = /// Opens a legacy binary (XLS) workbook from an existing stream. /// The stream containing the XLS data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . public static XlsWorkbook FromXls(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -79,7 +79,7 @@ public static XlsWorkbook FromXls(Stream stream, bool leaveOpen = true, ExcelRea } /// Opens a legacy binary (XLS) workbook directly from an in-memory buffer. - /// The whole XLS file's bytes. Must outlive the returned reader and must not be mutated while it is in use. + /// The whole XLS file's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . public static XlsWorkbook FromXls(ReadOnlyMemory data, ExcelReaderOptions? options = null) { @@ -96,7 +96,7 @@ public static XlsbWorkbook FromXlsbFile(string path, ExcelReaderOptions? options /// Opens an XLSB (Excel binary) workbook from an existing stream. /// The stream containing the XLSB data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . public static XlsbWorkbook FromXlsb(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null) { @@ -111,9 +111,9 @@ public static XlsbWorkbook FromXlsb(Stream stream, bool leaveOpen = true, ExcelR /// Opens an XLSB workbook directly from an in-memory buffer. Reads the ZIP /// central directory and decompresses parts without a ZipArchive /// or intermediate — every part is fully materialized up front, so the returned - /// reader never suspends, even under await foreach. + /// workbook never suspends, even under await foreach. /// - /// The whole XLSB file's bytes. Must outlive the returned reader. + /// The whole XLSB file's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . is ignored on this path — there is nothing left to overlap. public static XlsbWorkbook FromXlsb(ReadOnlyMemory data, ExcelReaderOptions? options = null) { @@ -138,7 +138,7 @@ public static ValueTask FromXlsxFileAsync(string path, ExcelReader /// Asynchronously opens an XLSX workbook from an existing stream. /// The stream containing the XLSX data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsxAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -162,7 +162,7 @@ public static ValueTask FromXlsFileAsync(string path, ExcelReaderOp /// Asynchronously opens a legacy binary (XLS) workbook from an existing stream. /// The stream containing the XLS data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -182,7 +182,7 @@ public static ValueTask FromXlsbFileAsync(string path, ExcelReader /// Asynchronously opens an XLSB (Excel binary) workbook from an existing stream. /// The stream containing the XLSB data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. public static ValueTask FromXlsbAsync(Stream stream, bool leaveOpen = true, ExcelReaderOptions? options = null, CancellationToken ct = default) @@ -213,7 +213,7 @@ public static CsvReader FromCsvFile(string path, CsvReaderOptions? options = nul /// Opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Delimiter, quote, encoding, and size-limit settings; when . public static CsvReader FromCsv(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null) { @@ -223,7 +223,7 @@ public static CsvReader FromCsv(Stream stream, bool leaveOpen = true, CsvReaderO } /// Opens a CSV (or other delimited-text) source directly from an in-memory buffer. - /// The whole CSV source's bytes. Must outlive the returned reader and must not be mutated while it is in use. + /// The whole CSV source's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// Delimiter, quote, encoding, and size-limit settings; when . public static CsvReader FromCsv(ReadOnlyMemory data, CsvReaderOptions? options = null) { @@ -252,7 +252,7 @@ public static async ValueTask FromCsvFileAsync(string path, CsvReader /// Asynchronously opens a CSV (or other delimited-text) source from an existing stream. /// The stream containing the CSV data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Delimiter, quote, encoding, and size-limit settings; when . /// A token to cancel the open operation. public static async ValueTask FromCsvAsync(Stream stream, bool leaveOpen = true, CsvReaderOptions? options = null, CancellationToken ct = default) @@ -334,7 +334,7 @@ public static IExcelWorkbook Open(string path, ExcelReaderOptions? options = nul /// stream's signature. /// /// A seekable stream containing the workbook data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A format-agnostic backed by the concrete workbook that matches the detected format. /// is . @@ -349,10 +349,10 @@ public static IExcelWorkbook Open(Stream stream, bool leaveOpen = true, ExcelRea /// /// Opens a workbook from an in-memory buffer, auto-detecting its format (XLSX/XLSB/XLS) from its /// signature. XLSX/XLSB route through instead of - /// a ZipArchive/, so the returned reader never + /// a ZipArchive/, so the returned workbook never /// suspends, even under await foreach. /// - /// The whole workbook file's bytes. Must outlive the returned reader. + /// The whole workbook file's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A format-agnostic backed by the concrete workbook that matches the detected format. /// The buffer's signature does not match a supported format. @@ -436,7 +436,7 @@ public static ValueTask OpenAsync(string path, ExcelReaderOption /// (XLSX/XLSB/XLS) from the stream's signature. /// /// A seekable stream containing the workbook data. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when . /// A token to cancel the open operation. /// A format-agnostic backed by the concrete workbook that matches the detected format. @@ -485,7 +485,7 @@ public static IExcelWorkbook Open(string path, ExcelFileFormat format, ExcelRead /// is . /// The format to read as. /// auto-detects XLSX/XLSB/XLS from the signature. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . @@ -511,7 +511,7 @@ public static IExcelWorkbook Open(Stream stream, ExcelFileFormat format, bool le /// /// Opens a workbook of a known format from an in-memory buffer. /// - /// The whole source's bytes. Must outlive the returned reader. + /// The whole source's bytes. Must stay valid and unmodified until the workbook and every enumerator obtained from it are disposed. /// The format to read as. /// auto-detects XLSX/XLSB/XLS from the signature. /// Resource limits and behavior toggles; when @@ -568,7 +568,7 @@ public static ValueTask OpenAsync(string path, ExcelFileFormat f /// is . /// The format to read as. /// auto-detects XLSX/XLSB/XLS from the signature. - /// When (the default), is not disposed when the workbook is disposed. + /// When (the default), is not disposed when the workbook is disposed, but the caller must not use, reposition or dispose it until the workbook and every enumerator obtained from it are disposed. /// Resource limits and behavior toggles; when /// . supplies the dialect when /// is . diff --git a/src/ExcelReader.Core/Reader/ExcelSheetList.cs b/src/ExcelReader.Core/Reader/ExcelSheetList.cs index c0d52cbe..17fdff2c 100644 --- a/src/ExcelReader.Core/Reader/ExcelSheetList.cs +++ b/src/ExcelReader.Core/Reader/ExcelSheetList.cs @@ -15,7 +15,13 @@ internal ExcelSheetList(TSheet[] sheets) } /// Gets the number of sheets. - public int Count => _sheets.Length; + public int Count + { + get + { + return _sheets.Length; + } + } /// Gets the sheet at the given zero-based index. /// The zero-based sheet index. Must be within [0, Count). diff --git a/src/ExcelReader.Core/Reader/IExcelWorkbook.cs b/src/ExcelReader.Core/Reader/IExcelWorkbook.cs index 5f19a4ae..4a01d69c 100644 --- a/src/ExcelReader.Core/Reader/IExcelWorkbook.cs +++ b/src/ExcelReader.Core/Reader/IExcelWorkbook.cs @@ -10,9 +10,11 @@ namespace ExcelReader.Core.Reader /// Thread safety: once opened, a workbook is safe to use from several threads: reading its /// metadata, obtaining sheets and creating enumerators. Each enumerator is used by one thread at a /// time; several enumerators of one workbook, including two over the same sheet, may run on - /// different threads. Using a single enumerator from two threads at once is undefined behaviour - /// and is not checked. A stream passed to a workbook must not be used by the caller while the - /// workbook is open. + /// different threads. The exception is a CSV over a non-seekable stream, which serves a single + /// enumerator: a second one throws . Using a single + /// enumerator from two threads at once is undefined behaviour and is not checked. A stream or + /// buffer passed to a workbook must not be used, modified or disposed by the caller until the + /// workbook and every enumerator obtained from it are disposed. /// /// Disposing the workbook closes it to new sheets and enumerators. Its file, stream and pooled /// buffers are released when the last enumerator obtained from it is disposed. @@ -58,12 +60,14 @@ public interface IExcelSheet /// Gets a new enumerator that reads the sheet's rows synchronously from the start. /// The sheet's workbook was disposed. + /// The source is a CSV over a non-seekable stream that was already enumerated. TEnumerator GetEnumerator(); /// Gets a new enumerator that reads the sheet's rows asynchronously from the start. /// Setup that needs I/O, such as opening the sheet part or loading shared strings, runs on the first , so this call does not block. /// A token observed by that deferred setup and by every call. /// The sheet's workbook was disposed. + /// The source is a CSV over a non-seekable stream that was already enumerated. TEnumerator GetAsyncEnumerator(CancellationToken ct = default); } diff --git a/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs index 8b2ed0a4..7df839d0 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsSheet.cs @@ -26,7 +26,13 @@ internal XlsSheet(XlsWorkbook workbook, int index, string name, ExcelSheetVisibi /// public bool IsDate1904 { get; } - private XlsWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + private XlsWorkbook Workbook + { + get + { + return _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + } + } /// public XlsWorkbook.Enumerator GetEnumerator() diff --git a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs index 009fef8e..bd0cca33 100644 --- a/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xls/XlsWorkbook.cs @@ -169,9 +169,21 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. /// The workbook was disposed. - public XlsSheet FirstSheet => Sheets[0]; + public XlsSheet FirstSheet + { + get + { + return Sheets[0]; + } + } - IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + IExcelSheet IExcelWorkbook.FirstSheet + { + get + { + return FirstSheet; + } + } /// public void Dispose() diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs index 21f86956..69a57008 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbSheet.cs @@ -26,7 +26,13 @@ internal XlsbSheet(XlsbWorkbook workbook, int index, string name, ExcelSheetVisi /// public bool IsDate1904 { get; } - private XlsbWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + private XlsbWorkbook Workbook + { + get + { + return _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + } + } /// public XlsbWorkbook.Enumerator GetEnumerator() diff --git a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs index 506de18f..57de30fe 100644 --- a/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsb/XlsbWorkbook.cs @@ -278,9 +278,21 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. /// The workbook was disposed. - public XlsbSheet FirstSheet => Sheets[0]; + public XlsbSheet FirstSheet + { + get + { + return Sheets[0]; + } + } - IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + IExcelSheet IExcelWorkbook.FirstSheet + { + get + { + return FirstSheet; + } + } /// diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs index 1ee1b744..866237f9 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxSheet.cs @@ -26,7 +26,13 @@ internal XlsxSheet(XlsxWorkbook workbook, int index, string name, ExcelSheetVisi /// public bool IsDate1904 { get; } - private XlsxWorkbook Workbook => _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + private XlsxWorkbook Workbook + { + get + { + return _workbook ?? throw new InvalidOperationException("This sheet was not obtained from a workbook."); + } + } /// public XlsxWorkbook.Enumerator GetEnumerator() diff --git a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs index 13a1b87e..912d0af6 100644 --- a/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs +++ b/src/ExcelReader.Core/Reader/Xlsx/XlsxWorkbook.cs @@ -210,9 +210,21 @@ internal Enumerator OpenSheetAsync(int index, CancellationToken ct) /// Gets the workbook's first sheet: the same sheet as Sheets[0]. Opens nothing. /// The workbook was disposed. - public XlsxSheet FirstSheet => Sheets[0]; + public XlsxSheet FirstSheet + { + get + { + return Sheets[0]; + } + } - IExcelSheet IExcelWorkbook.FirstSheet => FirstSheet; + IExcelSheet IExcelWorkbook.FirstSheet + { + get + { + return FirstSheet; + } + } internal ReadOnlySpan SharedSpan => _sharedFlat; diff --git a/src/ExcelReader.Native/NativeHandle.cs b/src/ExcelReader.Native/NativeHandle.cs index db8849b0..6ce14ecb 100644 --- a/src/ExcelReader.Native/NativeHandle.cs +++ b/src/ExcelReader.Native/NativeHandle.cs @@ -16,7 +16,13 @@ internal NativeHandle(IExcelWorkbook workbook) internal int CurrentSheet { get; private set; } - internal IExcelSheet Sheet => Workbook.SheetAt(CurrentSheet); + internal IExcelSheet Sheet + { + get + { + return Workbook.SheetAt(CurrentSheet); + } + } internal void MoveToSheet(int index) { diff --git a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs index 0f674337..29bdc52b 100644 --- a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs @@ -11,7 +11,7 @@ namespace ExcelReader.Tests.Native /// /// The one-live-session rule, from the outside. A xl_typed_reader/Arrow stream is the only /// thing in this ABI that holds an IExcelRowEnumerator open ACROSS calls, and - /// serves one usable enumerator at a time — so every + /// the native handle serves one row cursor at a time — so every /// other read on the same workbook either has to be refused up front or has to invalidate the /// live session loudly. These tests pin both halves; without the interlock they fail by returning /// XL_OK/XL_EOF with silently truncated data. From 8ce944c02575e396369392b5018455ea2547ad88 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 19:33:39 -0300 Subject: [PATCH 19/49] ci: publish the Rust crate without the local native-library override rust/.cargo/config.toml points EXCELREADER_NATIVE_LIB_DIR at a local build that the publish job never makes, so the package verification failed with "no .so file found" and excelreader 5.2.0 never reached crates.io. Remove the override in that job so the verification downloads the native library from the release, as users do. Let the derive step skip a version that is already published, so the job can be re-run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11877fef..e7559e70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -306,13 +306,21 @@ jobs: working-directory: rust/excelreader-derive env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish --allow-dirty + run: | + if ! out=$(cargo publish --allow-dirty 2>&1); then + echo "$out" + grep -q 'already exists' <<<"$out" || exit 1 + fi + # The workspace config points EXCELREADER_NATIVE_LIB_DIR at a local build that this job never makes; + # without it the package verification downloads the native library from the release, as users do. - name: Publish excelreader to crates.io working-directory: rust/excelreader env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish --allow-dirty + run: | + rm ../.cargo/config.toml + cargo publish --allow-dirty promote-public-api: name: Promote PublicAPI.Unshipped -> Shipped From ab2ee35ea46859caf4ecb20c0b0d11eb33756dce Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 19:34:22 -0300 Subject: [PATCH 20/49] docs: guide the reader through workbooks and sheets, and the move to v6 Rewrite the reading, parsing, CSV, encryption and writing guides for the workbook and sheet API, and add a v5 to v6 migration guide. Co-Authored-By: Claude Opus 5.5 --- docs/guide/csv.md | 24 ++--- docs/guide/encryption.md | 6 +- docs/guide/migrating-to-v6.md | 178 ++++++++++++++++++++++++++++++++++ docs/guide/parsing.md | 26 ++--- docs/guide/reading.md | 99 ++++++++++++------- docs/guide/writing.md | 2 +- 6 files changed, 271 insertions(+), 64 deletions(-) create mode 100644 docs/guide/migrating-to-v6.md diff --git a/docs/guide/csv.md b/docs/guide/csv.md index 58f0ef9c..074acf38 100644 --- a/docs/guide/csv.md +++ b/docs/guide/csv.md @@ -5,21 +5,21 @@ Parallel CSV parsing is covered in [parsing.md](parsing.md). ## Read CSV -`CsvReader` streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, `""`-escaped quotes) through the same `Row`/`Cell` model as the Excel readers, so `ExcelParser` works on it unchanged. +`CsvReader` is a workbook with exactly one, unnamed sheet (`csv.FirstSheet`, the same sheet as `csv.Sheets[0]`). It streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, `""`-escaped quotes) through the same `Row`/`Cell` model as the Excel workbooks, so `ExcelParser` works on it unchanged. ```csharp using ExcelReader.Core.Parser; using ExcelReader.Core.Reader; -using var reader = Excel.FromCsvFile("report.csv"); +using var csv = Excel.FromCsvFile("report.csv"); -foreach (var row in reader) +foreach (var row in csv.FirstSheet) { Console.WriteLine(row[0].GetString()); } -// Typed parsing works exactly like the Excel readers: -foreach (var item in ExcelParser.FromAttributes().Parse(reader)) +// Typed parsing works exactly like the Excel workbooks: +foreach (var item in ExcelParser.FromAttributes().Parse(csv.FirstSheet)) { Console.WriteLine($"{item.File}: +{item.LinesAdded}"); } @@ -29,17 +29,19 @@ foreach (var item in ExcelParser.FromAttributes().Parse(reader)) ```csharp var options = new CsvReaderOptions { Delimiter = (byte)';' }; -using var reader = Excel.FromCsvFile("relatorio.csv", options); +using var csv = Excel.FromCsvFile("relatorio.csv", options); ``` -Every CSV cell is text (`CellType.ExcelString`, or `CellType.Empty` for a blank field); at the reader level there is no binary numeric or date representation, so `Cell.TryGetDateTime`/`IsDate1904` (always `false` for CSV) do not apply. The typed parser, however, is CSV-specialized: `ExcelParser.Parse(CsvReader)` parses `DateTime`/`DateOnly` columns directly from the cell text (ISO or culture format, honoring `Culture` — e.g. pt-BR `02/07/2026`), so no `[ExcelConverter]` is needed for dates. All the usual attributes work unchanged (`[ExcelColumn]` aliases, `[ExcelRequired]`, `[ExcelConverter]`), and a converter still takes precedence over the built-in date parsing. (Holding the reader as `IExcelRowReader` instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete `Parse(CsvReader)` overload for CSV.) +Every CSV cell is text (`CellType.ExcelString`, or `CellType.Empty` for a blank field); at the reader level there is no binary numeric or date representation, so `Cell.TryGetDateTime`/`IsDate1904` (always `false` for CSV) do not apply. The typed parser, however, is CSV-specialized: `ExcelParser.Parse(CsvSheet)` parses `DateTime`/`DateOnly` columns directly from the cell text (ISO or culture format, honoring `Culture` — e.g. pt-BR `02/07/2026`), so no `[ExcelConverter]` is needed for dates. All the usual attributes work unchanged (`[ExcelColumn]` aliases, `[ExcelRequired]`, `[ExcelConverter]`), and a converter still takes precedence over the built-in date parsing. (Holding the sheet as an `IExcelSheet` instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete `Parse(CsvSheet)` overload for CSV.) + +A `CsvReader` over memory, a file path or a seekable stream allows several enumerators at once, each reading the sheet from the start. Over a seekable stream, every enumerator starts at the position the stream had when the `CsvReader` was created; where the stream's position ends up afterwards is unspecified, so do not rely on it (a file stream, and a memory stream with an exposed buffer, are read without touching it). A non-seekable stream can be read once: a second enumeration throws `InvalidOperationException`. `Excel.Open`/`OpenAsync` do **not** auto-detect CSV — plain text has no magic-byte signature to sniff. Name the format instead: every `Open`/`OpenAsync` overload takes an optional `ExcelFileFormat`, and `ExcelReaderOptions.Csv` carries the dialect, so a caller that decides the format from an extension or a content-type header configures both families through one options object: ```csharp ExcelFileFormat format = Path.GetExtension(path) is ".csv" ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown; -using IExcelRowReader reader = Excel.Open(path, format, new ExcelReaderOptions +using IExcelWorkbook workbook = Excel.Open(path, format, new ExcelReaderOptions { Password = secret, // used by the Excel formats Csv = CsvReaderOptions.Default with { SniffDialect = true }, // used by the CSV format @@ -57,7 +59,7 @@ using ExcelReader.Core.Reader; using ExcelReader.Core.Reader.Csv; CsvDialect dialect = CsvSniffer.DetectFile("export.csv"); -using var reader = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.WithDialect(dialect)); +using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.WithDialect(dialect)); ``` `CsvSniffer.Detect` takes a span of bytes or a seekable `Stream`, `DetectFile` takes a path, and `DetectAsync`/`DetectFileAsync` are the async siblings; only the first 64 KiB are examined. The `Stream` overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws `ArgumentException`; buffer it first, or pass the bytes as a span instead. Pass `CsvSnifferOptions` to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered. @@ -65,7 +67,7 @@ using var reader = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default.With `CsvReaderOptions.SniffDialect` does the sample-then-apply above for you, at the entry point that owns the source — the `Excel.FromCsv*` factories, `Excel.Open` with `ExcelFileFormat.Csv`, and the parallel CSV entry points: ```csharp -using var reader = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default with { SniffDialect = true }); +using var csv = Excel.FromCsvFile("export.csv", CsvReaderOptions.Default with { SniffDialect = true }); ``` An explicitly set `Encoding` survives sniffing unless the source carries a byte-order mark naming a different one — a sniffed encoding only ever comes from a BOM, so it never silently overrides a caller who knows their file is Windows-1252. @@ -97,7 +99,7 @@ using (CsvRowWriter row = writer.StartRow()) } ``` -Fields are quoted only when they contain the delimiter, quote character, `\r`, or `\n`; embedded quotes are doubled. `bool` writes as lowercase `true`/`false` and `DateTime`/`DateOnly` as round-trip ISO 8601 (`"O"`); `TimeOnly` as a time-of-day fraction — all matching what `ExcelParser.Parse(CsvReader)` expects, so a file written by `CsvWorkbookWriter` parses back without configuration. `Skip(count)` writes empty fields to keep column positions aligned (CSV has no sparse-cell concept). +Fields are quoted only when they contain the delimiter, quote character, `\r`, or `\n`; embedded quotes are doubled. `bool` writes as lowercase `true`/`false` and `DateTime`/`DateOnly` as round-trip ISO 8601 (`"O"`); `TimeOnly` as a time-of-day fraction — all matching what `ExcelParser.Parse(CsvSheet)` expects, so a file written by `CsvWorkbookWriter` parses back without configuration. `Skip(count)` writes empty fields to keep column positions aligned (CSV has no sparse-cell concept). `CsvWriterOptions` mirrors `CsvReaderOptions` property for property, so a file written with one set of settings reads back with the matching set: diff --git a/docs/guide/encryption.md b/docs/guide/encryption.md index 9e0ebfa1..d44b17b2 100644 --- a/docs/guide/encryption.md +++ b/docs/guide/encryption.md @@ -9,8 +9,8 @@ goes on the options, so every overload supports it: ```csharp var options = new ExcelReaderOptions { Password = "hunter2" }; -using IExcelRowReader reader = Excel.Open("protected.xlsx", options); -foreach (Row row in reader) { /* ... */ } +using IExcelWorkbook workbook = Excel.Open("protected.xlsx", options); +foreach (Row row in workbook.FirstSheet) { /* ... */ } ``` `ExcelEncryptionException.Reason` tells you what to do about a failure — only `PasswordRequired` and @@ -19,7 +19,7 @@ foreach (Row row in reader) { /* ... */ } ```csharp try { - using IExcelRowReader reader = Excel.Open(path, options); + using IExcelWorkbook workbook = Excel.Open(path, options); } catch (ExcelEncryptionException ex) when (ex.Reason is ExcelEncryptionReason.PasswordIncorrect) { diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md new file mode 100644 index 00000000..ec3caf59 --- /dev/null +++ b/docs/guide/migrating-to-v6.md @@ -0,0 +1,178 @@ +# Migrating from v5 to v6 + +What changed in the reading API and how to port v5 code. +See also [reading.md](reading.md) for the full reading guide. + +## What changed + +A v5 reader owned one "current sheet" that you moved with `MoveToSheet`; in v6 the reader became a workbook that hands out sheets, and each sheet is read on its own. Because a sheet no longer shares a cursor with its workbook, the sheets of one workbook can now be read in parallel. + +## Types + +| v5 | v6 | +|---|---| +| `XlsxReader`, `XlsbReader`, `XlsReader` | `XlsxWorkbook`, `XlsbWorkbook`, `XlsWorkbook` | +| `XlsxReader.Enumerator` (and the other two) | `XlsxWorkbook.Enumerator` (and `XlsbWorkbook.Enumerator`, `XlsWorkbook.Enumerator`) | +| `IExcelRowReader` | `IExcelWorkbook` and `IExcelSheet` | +| `IExcelRowReader` | `IExcelSheet` | +| `ExcelSheet`, `reader.Sheets()` | `XlsxSheet` / `XlsbSheet` / `XlsSheet` / `CsvSheet`, `workbook.Sheets` | +| `CsvReader` | `CsvReader` (same name; now a workbook with one sheet) | + +An explicit `ExcelEnumerable` declaration becomes `ExcelEnumerable` (likewise for XLSX and XLSB). + +A sheet is a small value (`XlsxSheet` and its siblings are `readonly struct`s) that holds no resources: it is a workbook plus an index. `IExcelSheet` is the format-agnostic view of one, with `Index`, `Name`, `Visibility` and `GetEnumerator()`. + +## Members + +| v5 | v6 | +|---|---| +| `reader.GetEnumerator()`, `foreach (Row row in reader)` | `workbook.FirstSheet.GetEnumerator()`, `foreach (Row row in workbook.FirstSheet)` | +| `reader.MoveToSheet(i)` then read | read `workbook.Sheets[i]` (`workbook.SheetAt(i)` on an `IExcelWorkbook`) | +| `reader.TryMoveToSheet(name)` | `workbook.TryGetSheet(name, out var sheet)` | +| `reader.SheetName`, `reader.SheetNameAt(i)` | `sheet.Name`, `workbook.Sheets[i].Name` | +| `reader.SheetVisibility`, `reader.SheetVisibilityAt(i)` | `sheet.Visibility` | +| `reader.SheetCount` | `workbook.Sheets.Count` (or `workbook.SheetCount` on `IExcelWorkbook`) | +| `parser.Parse(reader)` | `parser.Parse(workbook.FirstSheet)` | +| `Excel.InferSchema(reader)` | `Excel.InferSchema(sheet)` | +| `new ExcelDataReader(reader)` | `new ExcelDataReader(sheet)` | +| `reader.ToArrowRecordBatch()` | `sheet.ToArrowRecordBatch()` | +| `Excel.Open(...)` returning `IExcelRowReader` | `Excel.Open(...)` returning `IExcelWorkbook`; use `FirstSheet`, `SheetAt(i)` and `TryGetSheet` | + +`FirstSheet` is the same sheet as `Sheets[0]` and `SheetAt(0)`. + +## Before and after + +Reading the first sheet: + +```csharp +// v5 +using IExcelRowReader reader = Excel.Open("book.xlsx"); +foreach (Row row in reader) +{ + Console.WriteLine(row[0].GetString()); +} + +// v6 +using IExcelWorkbook workbook = Excel.Open("book.xlsx"); +foreach (Row row in workbook.FirstSheet) +{ + Console.WriteLine(row[0].GetString()); +} +``` + +Walking every sheet: + +```csharp +// v5 +using IExcelRowReader reader = Excel.Open("book.xlsx"); +for (int i = 0; i < reader.SheetCount; i++) +{ + reader.MoveToSheet(i); + Console.WriteLine(reader.SheetName); + foreach (Row row in reader) + { + Console.WriteLine(row[0].GetString()); + } +} + +// v6 +using IExcelWorkbook workbook = Excel.Open("book.xlsx"); +for (int i = 0; i < workbook.SheetCount; i++) +{ + IExcelSheet sheet = workbook.SheetAt(i); + Console.WriteLine(sheet.Name); + foreach (Row row in sheet) + { + Console.WriteLine(row[0].GetString()); + } +} +``` + +When you open a format directly (`Excel.FromXlsxFile` and the like), `workbook.Sheets` is a typed list: `foreach (XlsxSheet sheet in workbook.Sheets)` walks every sheet without boxing. + +v5's `ExcelSheet` had a positional `Deconstruct`, so `foreach (var (i, name) in reader.Sheets())` worked. The v6 sheets do not deconstruct; read the properties instead: + +```csharp +foreach (XlsxSheet sheet in workbook.Sheets) +{ + Console.WriteLine($"{sheet.Index}: {sheet.Name}"); +} +``` + +Parsing a named sheet: + +```csharp +// v5 +using IExcelRowReader reader = Excel.Open("changes.xlsx"); +if (reader.TryMoveToSheet("Changes")) +{ + foreach (ChangeRow item in ExcelParser.FromAttributes().Parse(reader)) + { + Console.WriteLine(item.File); + } +} + +// v6 +using IExcelWorkbook workbook = Excel.Open("changes.xlsx"); +if (workbook.TryGetSheet("Changes", out var sheet)) +{ + foreach (ChangeRow item in ExcelParser.FromAttributes().Parse(sheet)) + { + Console.WriteLine(item.File); + } +} +``` + +A CSV file: + +```csharp +// v5 +using CsvReader csv = Excel.FromCsvFile("book.csv"); +foreach (Row row in csv) +{ + Console.WriteLine(row[0].GetString()); +} + +// v6 +using CsvReader csv = Excel.FromCsvFile("book.csv"); +foreach (Row row in csv.FirstSheet) +{ + Console.WriteLine(row[0].GetString()); +} +``` + +## Reading sheets in parallel + +Each sheet of a workbook can be read on its own thread: + +```csharp +using XlsxWorkbook workbook = Excel.FromXlsxFile(path); + +Parallel.For(0, workbook.Sheets.Count, i => +{ + foreach (Row row in workbook.Sheets[i]) + { + // consume + } +}); +``` + +The contract: + +- A workbook is safe to share between threads once it is open: reading its metadata, getting sheets and creating enumerators all work concurrently. +- An enumerator belongs to one thread at a time. Using one enumerator from two threads at once is undefined behavior and is not checked. +- You may have several enumerators open at once, including several over the same sheet. The exception is a CSV over a non-seekable stream, which serves one. +- Do not use, reposition or dispose a stream or buffer you passed in until the workbook and every enumerator obtained from it are disposed. + +## Behavior changes + +- Getting a workbook's sheets (`Sheets`, `FirstSheet`, `SheetAt`, `TryGetSheet`) or opening an enumerator after `Dispose` throws `ObjectDisposedException` (since 5.2.0). `SheetCount` and `IsDate1904` still answer. +- Disposing a workbook does not close its file or stream while an enumerator from it is still undisposed; the resources are released when the last enumerator is disposed (since 5.2.0). +- A failed XLSX shared-string load fails every later enumeration the same way (since 5.2.0). +- A cancelled XLSX shared-string load is retried by the next enumeration, and the bytes it had read still count toward `MaxTotalDecompressedBytes` (since 5.2.0). +- A CSV over a seekable stream is read from the position the stream had when the `CsvReader` was created, by any number of enumerators at once. The stream's position afterwards is unspecified: file streams and memory streams with an exposed buffer are left untouched, any other seekable stream may be moved (since 5.2.0). +- A CSV over a non-seekable stream still allows one enumeration (since 5.2.0). + +## Native bindings + +The C ABI and the Python, Rust and C++ packages are unchanged in v6.0. diff --git a/docs/guide/parsing.md b/docs/guide/parsing.md index 80ad6bba..0f4214f3 100644 --- a/docs/guide/parsing.md +++ b/docs/guide/parsing.md @@ -20,10 +20,10 @@ public sealed class ChangeRow public int LinesAdded { get; set; } } -using var reader = Excel.FromXlsxFile("changes.xlsx"); +using var workbook = Excel.FromXlsxFile("changes.xlsx"); var parser = ExcelParser.FromAttributes(); -foreach (var item in parser.Parse(reader)) +foreach (var item in parser.Parse(workbook.FirstSheet)) { Console.WriteLine($"{item.File}: +{item.LinesAdded}"); } @@ -31,11 +31,11 @@ foreach (var item in parser.Parse(reader)) Built-in property types: `string`, `bool`, `DateTime`, `DateOnly`, `TimeOnly`, `TimeSpan`, `DateTimeOffset`, `Guid`, `char`, every integral and floating type plus `decimal`, and `enum`s (matched by member name or numeric value). Each also works as a `Nullable`. Empty cells leave the property at its default; an unparseable cell is skipped (keeps the default) unless the column is required. `T` needs no parameterless-constructor constraint, so models with `required` members are supported. -`Parse` also accepts the `IExcelRowReader` from `Excel.Open`, so you can parse without knowing the concrete format: +`Parse` also accepts the `IExcelSheet` of the `IExcelWorkbook` from `Excel.Open`, so you can parse without knowing the concrete format: ```csharp -using IExcelRowReader reader = Excel.Open("changes.xlsx"); // or .xlsb / .xls -foreach (var item in ExcelParser.FromAttributes().Parse(reader)) { /* ... */ } +using IExcelWorkbook workbook = Excel.Open("changes.xlsx"); // or .xlsb / .xls +foreach (var item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) { /* ... */ } ``` ## Generate typed maps at compile time (Native AOT / trimming) @@ -58,8 +58,8 @@ public partial class ChangeRow public int LinesAdded { get; set; } } -using var reader = Excel.FromXlsxFile("changes.xlsx"); -foreach (var item in ExcelParser.Generated().Parse(reader)) +using var workbook = Excel.FromXlsxFile("changes.xlsx"); +foreach (var item in ExcelParser.Generated().Parse(workbook.FirstSheet)) { Console.WriteLine($"{item.File}: +{item.LinesAdded}"); } @@ -100,8 +100,8 @@ var parser = ExcelParser.Build(builder => builder .Property(["file"], ExcelCellReaders.String, (ref ChangeRow r, string v) => r.File = v) .Property(["lines_added"], ExcelCellReaders.Parsable, (ref ChangeRow r, int v) => r.LinesAdded = v)); -using var reader = Excel.FromXlsxFile("changes.xlsx"); -foreach (var item in parser.Parse(reader)) +using var workbook = Excel.FromXlsxFile("changes.xlsx"); +foreach (var item in parser.Parse(workbook.FirstSheet)) { Console.WriteLine($"{item.File}: +{item.LinesAdded}"); } @@ -213,9 +213,9 @@ public readonly ref struct ChangeRowRef public int LinesAdded { get; init; } } -using var reader = Excel.FromXlsxFile("changes.xlsx"); +using var workbook = Excel.FromXlsxFile("changes.xlsx"); -foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(reader)) +foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) { Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}"); } @@ -224,9 +224,9 @@ foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(r The result of `Parse` supports `await foreach` (add `.WithCancellation(ct)` to pass a token), so a `ref struct` model can be parsed asynchronously — the rows are streamed via `MoveNextAsync` while the model stays a zero-copy `ref struct`: ```csharp -await using var reader = await Excel.FromXlsxFileAsync("changes.xlsx"); +await using var workbook = await Excel.FromXlsxFileAsync("changes.xlsx"); -await foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(reader)) +await foreach (ChangeRowRef item in ExcelParser.FromAttributes().Parse(workbook.FirstSheet)) { Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}"); } diff --git a/docs/guide/reading.md b/docs/guide/reading.md index 142c5d18..cf91604f 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -8,9 +8,9 @@ See also [parsing.md](parsing.md) for binding rows to types, and [csv.md](csv.md ```csharp using ExcelReader.Core.Reader; -using var reader = Excel.FromXlsxFile("report.xlsx"); +using var workbook = Excel.FromXlsxFile("report.xlsx"); -foreach (var row in reader) +foreach (var row in workbook.FirstSheet) { string name = row[0].GetString(); @@ -19,13 +19,15 @@ foreach (var row in reader) Console.WriteLine($"{name}: {quantity}"); } - if (row[2].Type == CellType.Date && row[2].TryGetDateTime(reader.IsDate1904, out var date)) + if (row[2].Type == CellType.Date && row[2].TryGetDateTime(workbook.IsDate1904, out var date)) { Console.WriteLine(date); } } ``` +An opened workbook (`XlsxWorkbook`, `XlsbWorkbook`, `XlsWorkbook`, `CsvReader`) hands out its sheets: `FirstSheet` is the first one, `Sheets[i]` any of them. A sheet holds no resources of its own; iterating it starts a new, independent read. Dispose the workbook, not the sheets. + ### Parallel CSV parsing (opt-in) ```csharp @@ -90,8 +92,8 @@ dotnet add package ExcelReader.Arrow using ExcelReader.Arrow; using ExcelReader.Core.Reader; -using var reader = Excel.FromXlsxFile("report.xlsx"); -Apache.Arrow.RecordBatch batch = reader.ToArrowRecordBatch(); +using var workbook = Excel.FromXlsxFile("report.xlsx"); +Apache.Arrow.RecordBatch batch = workbook.FirstSheet.ToArrowRecordBatch(); ``` `schema` defaults to `Excel.InferSchema`'s guess; pass an explicit `ExcelColumnSchema[]` to skip inference. The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs ~13% more time than an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. @@ -111,57 +113,57 @@ Supports string, large string, string view, every integer width (signed and unsi ## Open by auto-detecting the format -`Excel.Open` picks the reader from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an `IExcelRowReader`. The interface exposes `GetEnumerator()` directly, so no pattern-match is needed for basic row iteration. +`Excel.Open` picks the workbook type from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an `IExcelWorkbook`. `FirstSheet` gives the first sheet, and a sheet iterates its rows directly, so no pattern-match is needed for basic row iteration. ```csharp using ExcelReader.Core.Reader; -using IExcelRowReader reader = Excel.Open("report.xlsx"); // or report.xlsb / report.xls +using IExcelWorkbook workbook = Excel.Open("report.xlsx"); // or report.xlsb / report.xls -foreach (var row in reader) +foreach (var row in workbook.FirstSheet) { Console.WriteLine(row[0].GetString()); } ``` -Sheet navigation (`SheetCount`, `SheetName`, `MoveToSheet(index)`, `TryMoveToSheet(name)`) is available on `IExcelRowReader` itself, so you can walk every sheet without knowing the format: +`SheetCount`, `SheetAt(index)` and `TryGetSheet(name)` are on `IExcelWorkbook` itself, so you can walk every sheet without knowing the format. Each `IExcelSheet` carries its own `Index`, `Name` and `Visibility`: ```csharp -using IExcelRowReader reader = Excel.Open("report.xlsx"); +using IExcelWorkbook workbook = Excel.Open("report.xlsx"); -for (int i = 0; i < reader.SheetCount; i++) +for (int i = 0; i < workbook.SheetCount; i++) { - reader.MoveToSheet(i); - Console.WriteLine(reader.SheetName); - foreach (var row in reader) + IExcelSheet sheet = workbook.SheetAt(i); + Console.WriteLine(sheet.Name); + foreach (var row in sheet) { Console.WriteLine(row[0].GetString()); } } ``` -`Sheets()` is the same walk as an extension method, yielding an `ExcelSheet { Index, Name }` per sheet after selecting it as current: +`TryGetSheet` matches a name ignoring case and opens nothing: ```csharp -using IExcelRowReader reader = Excel.Open("report.xlsx"); - -foreach (var sheet in reader.Sheets()) +if (workbook.TryGetSheet("Totals", out var totals)) { - Console.WriteLine(sheet.Name); - foreach (var row in reader) // reads the sheet Sheets() just selected + foreach (var row in totals) { Console.WriteLine(row[0].GetString()); } } ``` -`SheetVisibility` and `SheetVisibilityAt(index)` report whether a sheet is shown in the workbook's tab bar, read from the format's own encoding of it (XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState`). It is reported, never enforced — a hidden sheet enumerates its rows like any other, so converters and exporters filter on it themselves: +When you open a format directly (`Excel.FromXlsxFile` and the like) the workbook's `Sheets` is a typed, indexable list (`Sheets.Count`, `Sheets[i]`, `foreach`) of `XlsxSheet`, `XlsbSheet` or `XlsSheet` values, so walking it boxes nothing. Reaching a sheet only looks up a name and an index; the file is not read until you iterate the sheet. + +`Visibility` reports whether a sheet is shown in the workbook's tab bar, read from the format's own encoding of it (XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState`). It is reported, never enforced — a hidden sheet enumerates its rows like any other, so converters and exporters filter on it themselves: ```csharp using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers -foreach (var sheet in reader.Sheets()) +for (int i = 0; i < workbook.SheetCount; i++) { + IExcelSheet sheet = workbook.SheetAt(i); if (sheet.Visibility != ExcelSheetVisibility.Visible) { continue; // skip hidden and veryHidden sheets @@ -172,38 +174,63 @@ foreach (var sheet in reader.Sheets()) `ExcelSheetVisibility.VeryHidden` is the state Excel's own unhide dialog does not offer; both it and `Hidden` come back here. A sheet whose format says nothing about its state — or says something no producer agrees on — reads as `Visible`. -CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `SheetName == ""`, always `Visible`). Pattern-match to the concrete type only for reader-specific internals beyond this surface. +CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `Name == ""`, always `Visible`). Pattern-match to the concrete type only for workbook-specific internals beyond this surface. `OpenAsync` is the async counterpart. Both require a seekable stream (or a file path) so the signature can be read without consuming the input. Detection covers the signed formats only. To open a source whose format you already know — including CSV, which has no signature to detect — pass an `ExcelFileFormat` and let `ExcelReaderOptions.Csv` carry the dialect; see [CSV](csv.md#read-csv). ```csharp -using IExcelRowReader reader = Excel.Open("report.csv", ExcelFileFormat.Csv); +using IExcelWorkbook workbook = Excel.Open("report.csv", ExcelFileFormat.Csv); ``` +## Reading sheets in parallel + +Each sheet of a workbook can be read on its own thread: + +```csharp +using XlsxWorkbook workbook = Excel.FromXlsxFile(path); + +Parallel.For(0, workbook.Sheets.Count, i => +{ + foreach (Row row in workbook.Sheets[i]) + { + // consume + } +}); +``` + +The contract: + +- A workbook is safe to share between threads once it is open: reading its metadata, getting sheets and creating enumerators all work concurrently. +- An enumerator belongs to one thread at a time. Using one enumerator from two threads at once is undefined behavior and is not checked. +- You may have several enumerators open at once, including several over the same sheet. +- Do not touch a stream you passed in while the workbook is open. + +Disposing the workbook closes it to new sheets and enumerators; its file or stream is released when the last enumerator already obtained from it is disposed. + ## Read asynchronously -Every reader supports `await foreach`. For XLSX files, the async reader buffers one row at a time and uses the same row parser as the sync reader, so sync and async reads stay behaviorally aligned while awaits happen only when more bytes are needed. +Every sheet supports `await foreach`. For XLSX files, the async enumerator buffers one row at a time and uses the same row parser as the synchronous enumerator, so sync and async reads stay behaviorally aligned while awaits happen only when more bytes are needed. ```csharp using ExcelReader.Core.Reader; -await using var reader = await Excel.FromXlsxFileAsync("report.xlsx", cancellationToken); +await using var workbook = await Excel.FromXlsxFileAsync("report.xlsx", ct: cancellationToken); -await foreach (var row in reader) +await foreach (var row in workbook.FirstSheet) { Console.WriteLine(row[0].GetString()); } ``` -`await foreach` binds to the reader's `GetAsyncEnumerator(CancellationToken ct = default)` by pattern. The call itself does no I/O: opening the sheet part (and, for XLSX, loading shared strings) happens asynchronously on the first `MoveNextAsync`. Because `Row` and `Cell` are `ref struct` types, the current row cannot be held across an `await` inside the loop body: read its cells (or copy the values out) before awaiting anything else. +`await foreach` binds to the sheet's `GetAsyncEnumerator(CancellationToken ct = default)` by pattern. The call itself does no I/O: opening the sheet part (and, for XLSX, loading shared strings) happens asynchronously on the first `MoveNextAsync`. Because `Row` and `Cell` are `ref struct` types, the current row cannot be held across an `await` inside the loop body: read its cells (or copy the values out) before awaiting anything else. `await foreach` does not accept `.WithCancellation(ct)`: `Row` being a `ref struct` rules out `IAsyncEnumerable`, so the loop binds to the pattern rather than the interface. To pass a token, or to `await` while a row is in scope, drive the enumerator manually: ```csharp -await using var reader = await Excel.FromXlsxFileAsync("report.xlsx", cancellationToken); -await using var rows = reader.GetAsyncEnumerator(cancellationToken); +await using var workbook = await Excel.FromXlsxFileAsync("report.xlsx", ct: cancellationToken); +await using var rows = workbook.FirstSheet.GetAsyncEnumerator(cancellationToken); while (await rows.MoveNextAsync()) { @@ -224,9 +251,9 @@ decompress, so the option is silently ignored for them. ```csharp var options = new ExcelReaderOptions { PrefetchDecompression = true }; -using var reader = Excel.FromXlsxFile("report.xlsx", options); +using var workbook = Excel.FromXlsxFile("report.xlsx", options); -foreach (var row in reader) +foreach (var row in workbook.FirstSheet) { Console.WriteLine(row[0].GetString()); } @@ -257,20 +284,20 @@ Writing has the same option, under a different name: see ## Bridge to ADO.NET (`IDataReader`) -`ExcelDataReader` adapts an `IExcelRowReader`'s current sheet to `System.Data.IDataReader`, so it drops straight into `SqlBulkCopy`, `DataTable.Load`, Dapper, or any other ADO.NET consumer: +`ExcelDataReader` adapts one `IExcelSheet` to `System.Data.IDataReader`, so it drops straight into `SqlBulkCopy`, `DataTable.Load`, Dapper, or any other ADO.NET consumer: ```csharp using System.Data; using ExcelReader.Core.Reader; -using IExcelRowReader reader = Excel.Open("report.xlsx"); -using IDataReader data = new ExcelDataReader(reader); // headerRow: 1 by default +using IExcelWorkbook workbook = Excel.Open("report.xlsx"); +using IDataReader data = new ExcelDataReader(workbook.FirstSheet); // headerRow: 1 by default var table = new DataTable(); table.Load(data); ``` -The header row (1-based, default 1) fixes the column shape; pass `headerRow: 0` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; combine with `Sheets()` to build one `ExcelDataReader` per sheet instead of chaining result sets. +The header row (1-based, default 1) fixes the column shape; pass `headerRow: 0` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `ExcelDataReader` per sheet instead of chaining result sets. The bridge costs ~1.4–1.5x over iterating rows directly, and `GetBytes` stays within ~10% of the direct read. See [ADO.NET bridge](../performance/benchmarks.md#adonet-bridge-idatareader) in the benchmarks. diff --git a/docs/guide/writing.md b/docs/guide/writing.md index 5d12decc..e2e9e6c8 100644 --- a/docs/guide/writing.md +++ b/docs/guide/writing.md @@ -41,7 +41,7 @@ await using var workbook = XlsxWorkbookWriter.Create(stream, options: new XlsxWr ## Hidden sheets -Every `IWorkbookWriter` takes an optional `ExcelSheetVisibility` alongside the sheet name, written to whatever the format uses for it — XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState` — so it reads back through [`SheetVisibility`](reading.md#open-by-auto-detecting-the-format): +Every `IWorkbookWriter` takes an optional `ExcelSheetVisibility` alongside the sheet name, written to whatever the format uses for it — XLSX's `state` attribute, XLSB's `BrtBundleSh.hsState`, XLS's `BoundSheet8.hsState` — so it reads back through [`IExcelSheet.Visibility`](reading.md#open-by-auto-detecting-the-format): ```csharp using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers From 053f876d3a17c96336567a063947e7ae7b8fa833 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 6 Oct 2026 19:35:03 -0300 Subject: [PATCH 21/49] build: add IDE1006 to NoWarn list in Directory.Build.props --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8eeb3a89..f9b76a42 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,7 @@ new overload later can rebind existing callers). Several intentional overloads (e.g. Excel.Open) already accept this tradeoff for ergonomics; fixing it means redesigning those APIs, not a PublicAPI.txt entry, so it stays suppressed. --> - $(NoWarn);CA1062;RS0026;CA1819;CA1515;MA0048 + $(NoWarn);CA1062;RS0026;CA1819;CA1515;MA0048;IDE1006 true all high From 95c095b05b78deaf90b1e2221d13d3f40537d3c2 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 6 Oct 2026 23:48:34 -0300 Subject: [PATCH 22/49] feat(native)!: pass a sheet index to typed, Arrow and schema reads Each read opens its own enumerator, so reads on one workbook handle no longer invalidate one another and the live-session interlock is removed. xl_parse_typed_ex, xl_parse_arrow_ex and xl_infer_schema_ex fold into the unsuffixed functions. XL_ABI_VERSION is 6. Co-Authored-By: Claude Sonnet 5.5 --- rust/excelreader/excelreader.def | 3 - .../Arrow/ArrowApi.Stream.cs | 4 +- src/ExcelReader.Native/Arrow/ArrowApi.cs | 10 +- src/ExcelReader.Native/Exports.Arrow.cs | 22 +- src/ExcelReader.Native/Exports.Reading.cs | 17 +- src/ExcelReader.Native/Exports.Typed.cs | 21 +- src/ExcelReader.Native/NativeHandle.cs | 20 -- src/ExcelReader.Native/NativeStatus.cs | 2 +- .../Reading/ReadApi.Rows.All.cs | 1 - .../Reading/ReadApi.Rows.cs | 2 - .../Reading/ReadApi.Schema.cs | 16 +- .../Reading/ReadApi.Sheets.cs | 22 ++ .../Typed/TypedApi.Chunked.cs | 4 +- .../Typed/TypedApi.Parallel.cs | 13 +- .../Typed/TypedApi.Session.cs | 65 +---- src/ExcelReader.Native/Typed/TypedApi.cs | 8 +- .../include/excelreader.def | 3 - src/ExcelReader.Native/include/excelreader.h | 37 ++- .../include/excelreader_arrow.h | 14 +- .../Native/ChunkedParseBenchmark.cs | 4 +- .../Native/NativeTypedParseBenchmark.cs | 4 +- tests/ExcelReader.NativePgoTrainer/Program.cs | 2 +- .../Arrow/ArrowConversionTests.cs | 2 +- .../Native/ArrowStreamTests.cs | 23 +- .../Native/NativeApiTests.Arrow.cs | 12 +- .../Native/NativeApiTests.InferSchemaText.cs | 10 +- .../Native/NativeApiTests.ParallelTyped.cs | 39 +-- .../Native/NativeApiTests.Reading.cs | 14 +- .../Native/NativeApiTests.Sheets.cs | 75 +++++ .../Native/NativeApiTests.Typed.cs | 32 +-- .../Native/NativeApiTests.Writer.cs | 18 +- .../Native/SessionLifetimeTests.cs | 267 ++++-------------- .../Native/TypedParseSessionTests.cs | 14 +- 33 files changed, 297 insertions(+), 503 deletions(-) create mode 100644 tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs diff --git a/rust/excelreader/excelreader.def b/rust/excelreader/excelreader.def index 63b1bec4..0e8f3cc6 100644 --- a/rust/excelreader/excelreader.def +++ b/rust/excelreader/excelreader.def @@ -16,13 +16,11 @@ EXPORTS xl_read_all_blob xl_free_rows xl_parse_typed - xl_parse_typed_ex xl_free_table xl_typed_reader_open xl_typed_reader_next xl_typed_reader_close xl_parse_arrow - xl_parse_arrow_ex xl_parse_arrow_stream xl_write_typed xl_write_typed_to_memory @@ -30,7 +28,6 @@ EXPORTS xl_encrypt_package xl_encrypt_package_to_memory xl_infer_schema - xl_infer_schema_ex xl_free_schema xl_open_write_handle xl_open_write_handle_to_memory diff --git a/src/ExcelReader.Native/Arrow/ArrowApi.Stream.cs b/src/ExcelReader.Native/Arrow/ArrowApi.Stream.cs index 2983bcb8..f34889b5 100644 --- a/src/ExcelReader.Native/Arrow/ArrowApi.Stream.cs +++ b/src/ExcelReader.Native/Arrow/ArrowApi.Stream.cs @@ -41,11 +41,11 @@ public void Dispose() } } - internal static int OpenArrowStream(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, + internal static int OpenArrowStream(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, long maxRows, out ArrowArrayStream stream) { stream = default; - int status = TypedApi.TypedParseSession.Open(handle, specs, headerRow, maxRows, out TypedApi.TypedParseSession? session); + int status = TypedApi.TypedParseSession.Open(handle, sheet, specs, headerRow, maxRows, out TypedApi.TypedParseSession? session); if (status != NativeStatus.Ok) { return status; diff --git a/src/ExcelReader.Native/Arrow/ArrowApi.cs b/src/ExcelReader.Native/Arrow/ArrowApi.cs index eb7643a7..2efc8194 100644 --- a/src/ExcelReader.Native/Arrow/ArrowApi.cs +++ b/src/ExcelReader.Native/Arrow/ArrowApi.cs @@ -8,17 +8,17 @@ namespace ExcelReader.Native.Arrow { internal static unsafe partial class ArrowApi { - internal static int ParseArrow(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, out ArrowArray array, out ArrowSchema schema) + internal static int ParseArrow(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, out ArrowArray array, out ArrowSchema schema) { - return ParseArrow(handle, specs, headerRow, 1, "xl_parse_arrow", out array, out schema); + return ParseArrow(handle, sheet, specs, headerRow, 1, out array, out schema); } - internal static int ParseArrow(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, int degreeOfParallelism, - string cause, out ArrowArray array, out ArrowSchema schema) + internal static int ParseArrow(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, int degreeOfParallelism, + out ArrowArray array, out ArrowSchema schema) { array = default; schema = default; - int status = TypedApi.ParseTypedTable(handle, specs, headerRow, degreeOfParallelism, cause, out NativeTable table); + int status = TypedApi.ParseTypedTable(handle, sheet, specs, headerRow, degreeOfParallelism, out NativeTable table); if (status != NativeStatus.Ok) { return status; diff --git a/src/ExcelReader.Native/Exports.Arrow.cs b/src/ExcelReader.Native/Exports.Arrow.cs index 9a462160..222e8c00 100644 --- a/src/ExcelReader.Native/Exports.Arrow.cs +++ b/src/ExcelReader.Native/Exports.Arrow.cs @@ -7,20 +7,8 @@ namespace ExcelReader.Native internal static unsafe partial class Exports { [UnmanagedCallersOnly(EntryPoint = "xl_parse_arrow")] - public static int ParseArrow(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, ArrowArray* outArray, ArrowSchema* outSchema) - { - return ParseArrowTable(handle, specs, specCount, headerRow, 1, "xl_parse_arrow", outArray, outSchema); - } - - [UnmanagedCallersOnly(EntryPoint = "xl_parse_arrow_ex")] - public static int ParseArrowEx(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, - ArrowArray* outArray, ArrowSchema* outSchema) - { - return ParseArrowTable(handle, specs, specCount, headerRow, degreeOfParallelism, "xl_parse_arrow_ex", outArray, outSchema); - } - - private static int ParseArrowTable(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, - string cause, ArrowArray* outArray, ArrowSchema* outSchema) + public static int ParseArrow(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, + int degreeOfParallelism, ArrowArray* outArray, ArrowSchema* outSchema) { if (specs is null || outArray is null || outSchema is null || !TypedApi.IsValidSpecCount(specCount)) { @@ -36,7 +24,7 @@ private static int ParseArrowTable(nint handle, NativeColumnSpecRaw* specs, int return NativeStatus.InvalidArgument; } - int status = ArrowApi.ParseArrow(Resolve(handle), decoded, headerRow, degreeOfParallelism, cause, out ArrowArray array, out ArrowSchema schema); + int status = ArrowApi.ParseArrow(Resolve(handle), sheet, decoded, headerRow, degreeOfParallelism, out ArrowArray array, out ArrowSchema schema); *outArray = array; *outSchema = schema; return status; @@ -51,7 +39,7 @@ private static int ParseArrowTable(nint handle, NativeColumnSpecRaw* specs, int } [UnmanagedCallersOnly(EntryPoint = "xl_parse_arrow_stream")] - public static int ParseArrowStream(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, + public static int ParseArrowStream(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, long maxRows, ArrowArrayStream* outStream) { if (outStream is null) @@ -71,7 +59,7 @@ public static int ParseArrowStream(nint handle, NativeColumnSpecRaw* specs, int return NativeStatus.InvalidArgument; } - int status = ArrowApi.OpenArrowStream(Resolve(handle), decoded, headerRow, maxRows, + int status = ArrowApi.OpenArrowStream(Resolve(handle), sheet, decoded, headerRow, maxRows, out ArrowArrayStream stream); *outStream = stream; return status; diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index 146324ae..1b5df6d2 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -205,27 +205,14 @@ public static void FreeRows(NativeRows* rows) } [UnmanagedCallersOnly(EntryPoint = "xl_infer_schema")] - public static int InferSchema(nint handle, int headerRow, int sampleSize, NativeInferredSchema* outSchema) + public static int InferSchema(nint handle, int sheet, int headerRow, int sampleSize, int flags, NativeInferredSchema* outSchema) { if (outSchema is null) { return NativeStatus.InvalidArgument; } - int status = ReadApi.InferSchema(Resolve(handle), headerRow, sampleSize, out NativeInferredSchema schema); - *outSchema = schema; - return status; - } - - [UnmanagedCallersOnly(EntryPoint = "xl_infer_schema_ex")] - public static int InferSchemaEx(nint handle, int headerRow, int sampleSize, int flags, NativeInferredSchema* outSchema) - { - if (outSchema is null) - { - return NativeStatus.InvalidArgument; - } - - int status = ReadApi.InferSchema(Resolve(handle), headerRow, sampleSize, flags, out NativeInferredSchema schema); + int status = ReadApi.InferSchema(Resolve(handle), sheet, headerRow, sampleSize, flags, out NativeInferredSchema schema); *outSchema = schema; return status; } diff --git a/src/ExcelReader.Native/Exports.Typed.cs b/src/ExcelReader.Native/Exports.Typed.cs index 83e916a5..7901e5fa 100644 --- a/src/ExcelReader.Native/Exports.Typed.cs +++ b/src/ExcelReader.Native/Exports.Typed.cs @@ -6,19 +6,8 @@ namespace ExcelReader.Native internal static unsafe partial class Exports { [UnmanagedCallersOnly(EntryPoint = "xl_parse_typed")] - public static int ParseTyped(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, NativeTable* outTable) - { - return ParseTypedTable(handle, specs, specCount, headerRow, 1, "xl_parse_typed", outTable); - } - - [UnmanagedCallersOnly(EntryPoint = "xl_parse_typed_ex")] - public static int ParseTypedEx(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, NativeTable* outTable) - { - return ParseTypedTable(handle, specs, specCount, headerRow, degreeOfParallelism, "xl_parse_typed_ex", outTable); - } - - private static int ParseTypedTable(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, - string cause, NativeTable* outTable) + public static int ParseTyped(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, + int degreeOfParallelism, NativeTable* outTable) { if (specs is null || outTable is null || !TypedApi.IsValidSpecCount(specCount)) { @@ -33,7 +22,7 @@ private static int ParseTypedTable(nint handle, NativeColumnSpecRaw* specs, int return NativeStatus.InvalidArgument; } - int status = TypedApi.ParseTypedTable(Resolve(handle), decoded, headerRow, degreeOfParallelism, cause, out NativeTable table); + int status = TypedApi.ParseTypedTable(Resolve(handle), sheet, decoded, headerRow, degreeOfParallelism, out NativeTable table); *outTable = table; return status; } @@ -63,7 +52,7 @@ public static void FreeTable(NativeTable* table) } [UnmanagedCallersOnly(EntryPoint = "xl_typed_reader_open")] - public static int TypedReaderOpen(nint handle, NativeColumnSpecRaw* specs, int specCount, int headerRow, + public static int TypedReaderOpen(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, long maxRows, nint* outReader) { if (outReader is null) @@ -83,7 +72,7 @@ public static int TypedReaderOpen(nint handle, NativeColumnSpecRaw* specs, int s return NativeStatus.InvalidArgument; } - int status = TypedApi.OpenTypedReader(Resolve(handle), decoded, headerRow, maxRows, out nint reader); + int status = TypedApi.OpenTypedReader(Resolve(handle), sheet, decoded, headerRow, maxRows, out nint reader); *outReader = reader; return status; } diff --git a/src/ExcelReader.Native/NativeHandle.cs b/src/ExcelReader.Native/NativeHandle.cs index 6ce14ecb..99348d60 100644 --- a/src/ExcelReader.Native/NativeHandle.cs +++ b/src/ExcelReader.Native/NativeHandle.cs @@ -1,6 +1,5 @@ using ExcelReader.Core.Reader; using ExcelReader.Native.Reading; -using ExcelReader.Native.Typed; namespace ExcelReader.Native { @@ -29,7 +28,6 @@ internal void MoveToSheet(int index) _ = Workbook.SheetAt(index); CurrentSheet = index; ResetRows(); - FaultLiveSession("xl_move_to_sheet"); } internal IExcelRowEnumerator? Rows { get; set; } @@ -50,23 +48,6 @@ internal void MoveToSheet(int index) internal bool AllRowsPending { get; set; } - internal TypedApi.TypedParseSession? LiveSession { get; set; } - - internal void FaultLiveSession(string cause) - { - LiveSession?.Fault($"this chunked read was invalidated by {cause} on the same workbook: a " + - "workbook serves one row cursor at a time, so this read's position is no longer defined. " + - "Finish or close the read before using the workbook for anything else."); - } - - internal void ReleaseLiveSession(TypedApi.TypedParseSession session) - { - if (ReferenceEquals(LiveSession, session)) - { - LiveSession = null; - } - } - internal void ResetRows() { Rows?.Dispose(); @@ -81,7 +62,6 @@ internal void ResetRows() public void Dispose() { - FaultLiveSession("xl_close"); ResetRows(); View?.Dispose(); Workbook.Dispose(); diff --git a/src/ExcelReader.Native/NativeStatus.cs b/src/ExcelReader.Native/NativeStatus.cs index 74498d02..10dac12c 100644 --- a/src/ExcelReader.Native/NativeStatus.cs +++ b/src/ExcelReader.Native/NativeStatus.cs @@ -12,7 +12,7 @@ internal static class NativeStatus internal const int PasswordRequired = -6; internal const int PasswordIncorrect = -7; - internal const int AbiVersion = 5; + internal const int AbiVersion = 6; } internal static class NativeLimits diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs index f54ee48b..31ab58e0 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs @@ -57,7 +57,6 @@ private static void AccumulateAllRows(NativeHandle handle) } byte[] rowScratch = []; - handle.FaultLiveSession("xl_read_all_blob"); handle.Rows ??= handle.Sheet.GetEnumerator(); while (handle.Rows.MoveNext()) { diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs index daebd2e5..59462da6 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs @@ -49,7 +49,6 @@ internal static int NextRow(NativeHandle? handle, Span buffer, out int wri { if (!handle.HasPending) { - handle.FaultLiveSession("xl_next_row"); handle.Rows ??= handle.Sheet.GetEnumerator(); if (!handle.Rows.MoveNext()) { @@ -99,7 +98,6 @@ internal static int NextRowView(NativeHandle? handle, out NativeRow row) return NativeStatus.Ok; } - handle.FaultLiveSession("xl_next_row_view"); handle.Rows ??= handle.Sheet.GetEnumerator(); if (!handle.Rows.MoveNext()) { diff --git a/src/ExcelReader.Native/Reading/ReadApi.Schema.cs b/src/ExcelReader.Native/Reading/ReadApi.Schema.cs index 64dd2449..09ff4d0d 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Schema.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Schema.cs @@ -9,12 +9,7 @@ internal static unsafe partial class ReadApi { internal const int InferParseText = 1; - internal static int InferSchema(NativeHandle? handle, int headerRow, int sampleSize, out NativeInferredSchema schema) - { - return InferSchema(handle, headerRow, sampleSize, 0, out schema); - } - - internal static int InferSchema(NativeHandle? handle, int headerRow, int sampleSize, int flags, out NativeInferredSchema schema) + internal static int InferSchema(NativeHandle? handle, int sheet, int headerRow, int sampleSize, int flags, out NativeInferredSchema schema) { schema = default; if (handle is null) @@ -38,11 +33,16 @@ internal static int InferSchema(NativeHandle? handle, int headerRow, int sampleS } NativeApi.ClearLastError(); + int status = ResolveSheet(handle, sheet, out IExcelSheet? resolved); + if (status != NativeStatus.Ok) + { + return status; + } + IExcelRowEnumerator? rows = null; try { - handle.FaultLiveSession("xl_infer_schema"); - rows = handle.Sheet.GetEnumerator(); + rows = resolved!.GetEnumerator(); schema = BuildSchema(SchemaInference.Infer(rows, handle.Workbook.IsDate1904, headerRow, sampleSize, (flags & InferParseText) != 0)); return NativeStatus.Ok; } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs index 0687f1ae..1e4d94d2 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs @@ -1,9 +1,31 @@ using System.Text; +using ExcelReader.Core.Reader; namespace ExcelReader.Native.Reading { internal static partial class ReadApi { + internal static int ResolveSheet(NativeHandle handle, int sheet, out IExcelSheet? resolved) + { + resolved = null; + if (sheet < 0) + { + NativeApi.SetLastError($"sheet must be a zero-based index; got {sheet}."); + return NativeStatus.InvalidArgument; + } + + try + { + resolved = handle.Workbook.SheetAt(sheet); + return NativeStatus.Ok; + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } + } + internal static int SheetCount(NativeHandle? handle, out int count) { count = 0; diff --git a/src/ExcelReader.Native/Typed/TypedApi.Chunked.cs b/src/ExcelReader.Native/Typed/TypedApi.Chunked.cs index 7b1da013..b16d157d 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Chunked.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Chunked.cs @@ -2,11 +2,11 @@ namespace ExcelReader.Native.Typed { internal static unsafe partial class TypedApi { - internal static int OpenTypedReader(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, + internal static int OpenTypedReader(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, long maxRows, out nint reader) { reader = 0; - int status = TypedParseSession.Open(handle, specs, headerRow, maxRows, out TypedParseSession? session); + int status = TypedParseSession.Open(handle, sheet, specs, headerRow, maxRows, out TypedParseSession? session); if (status != NativeStatus.Ok) { return status; diff --git a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs index 12bf8939..db105d17 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Parallel.cs @@ -9,8 +9,8 @@ internal static unsafe partial class TypedApi [ThreadStatic] internal static bool LastParseRanInParallel; - internal static int ParseTypedTable(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, int degreeOfParallelism, - string cause, out NativeTable table, int chunkSizeOverride = 0) + internal static int ParseTypedTable(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, int degreeOfParallelism, + out NativeTable table, int chunkSizeOverride = 0) { table = default; LastParseRanInParallel = false; @@ -24,17 +24,17 @@ internal static int ParseTypedTable(NativeHandle? handle, NativeColumnSpec[] spe return NativeStatus.InvalidArgument; } int dop = ParallelCsvFactory.Normalize(degreeOfParallelism); - if (dop > 1 && handle.Workbook is CsvReader csv && csv.TryGetChunkSource(out CsvChunkSource source) + if (sheet == 0 && dop > 1 && handle.Workbook is CsvReader csv && csv.TryGetChunkSource(out CsvChunkSource source) && ParallelCsvFactory.CanPartition(dop, source.Length, csv.Options)) { LastParseRanInParallel = true; - return ParseCsvInParallel(handle, source, csv.Options, specs, headerRow, degreeOfParallelism, cause, chunkSizeOverride, out table); + return ParseCsvInParallel(handle, source, csv.Options, specs, headerRow, degreeOfParallelism, chunkSizeOverride, out table); } - return ParseSequential(handle, specs, headerRow, cause, out table); + return ParseSequential(handle, sheet, specs, headerRow, out table); } private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source, CsvReaderOptions reader, NativeColumnSpec[] specs, - int headerRow, int degreeOfParallelism, string cause, int chunkSizeOverride, out NativeTable table) + int headerRow, int degreeOfParallelism, int chunkSizeOverride, out NativeTable table) { table = default; if (!TryValidateArguments(specs, headerRow, out string? argumentError)) @@ -42,7 +42,6 @@ private static int ParseCsvInParallel(NativeHandle handle, CsvChunkSource source NativeApi.SetLastError(argumentError); return NativeStatus.InvalidArgument; } - handle.FaultLiveSession(cause); NativeApi.ClearLastError(); try { diff --git a/src/ExcelReader.Native/Typed/TypedApi.Session.cs b/src/ExcelReader.Native/Typed/TypedApi.Session.cs index 31da2c88..3fb46e04 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.Session.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.Session.cs @@ -1,4 +1,5 @@ using ExcelReader.Core.Reader; +using ExcelReader.Native.Reading; namespace ExcelReader.Native.Typed { @@ -6,7 +7,6 @@ internal static unsafe partial class TypedApi { internal sealed class TypedParseSession : IDisposable { - private readonly NativeHandle _handle; private readonly NativeColumnSpec[] _specs; private readonly int[] _columnIndices; private readonly long _maxRows; @@ -16,10 +16,9 @@ internal sealed class TypedParseSession : IDisposable private string? _faultMessage; private bool _disposed; - private TypedParseSession(NativeHandle handle, NativeColumnSpec[] specs, int[] columnIndices, + private TypedParseSession(NativeColumnSpec[] specs, int[] columnIndices, long maxRows, bool isDate1904, IExcelRowEnumerator rows) { - _handle = handle; _specs = specs; _columnIndices = columnIndices; _maxRows = maxRows; @@ -27,7 +26,7 @@ private TypedParseSession(NativeHandle handle, NativeColumnSpec[] specs, int[] c _rows = rows; } - internal static int Open(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, long maxRows, + internal static int Open(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, long maxRows, out TypedParseSession? session) { session = null; @@ -35,36 +34,6 @@ internal static int Open(NativeHandle? handle, NativeColumnSpec[] specs, int hea { return NativeStatus.InvalidHandle; } - if (handle.LiveSession is not null) - { - NativeApi.SetLastError("this workbook already has a chunked read open; close it " - + "(xl_typed_reader_close, or the Arrow stream's own release) before opening another."); - return NativeStatus.Error; - } - - int status = OpenCore(handle, specs, headerRow, maxRows, faultCause: null, out session); - if (status == NativeStatus.Ok) - { - handle.LiveSession = session; - } - return status; - } - - internal static int OpenTransient(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, - string cause, out TypedParseSession? session) - { - session = null; - if (handle is null) - { - return NativeStatus.InvalidHandle; - } - return OpenCore(handle, specs, headerRow, maxRows: 0, faultCause: cause, out session); - } - - private static int OpenCore(NativeHandle handle, NativeColumnSpec[] specs, int headerRow, long maxRows, - string? faultCause, out TypedParseSession? session) - { - session = null; if (maxRows < 0) { NativeApi.SetLastError($"max_rows must be 0 (unbounded) or positive; got {maxRows}."); @@ -76,16 +45,17 @@ private static int OpenCore(NativeHandle handle, NativeColumnSpec[] specs, int h return NativeStatus.InvalidArgument; } - if (faultCause is not null) + NativeApi.ClearLastError(); + int status = ReadApi.ResolveSheet(handle, sheet, out IExcelSheet? resolved); + if (status != NativeStatus.Ok) { - handle.FaultLiveSession(faultCause); + return status; } - NativeApi.ClearLastError(); IExcelRowEnumerator? rows = null; try { - rows = handle.Sheet.GetEnumerator(); + rows = resolved!.GetEnumerator(); int[] columnIndices = new int[specs.Length]; if (!TryResolveColumns(rows, specs, headerRow, columnIndices, out string? resolveError)) { @@ -94,8 +64,7 @@ private static int OpenCore(NativeHandle handle, NativeColumnSpec[] specs, int h return NativeStatus.InvalidArgument; } - session = new TypedParseSession(handle, specs, columnIndices, maxRows, - handle.Workbook.IsDate1904, rows); + session = new TypedParseSession(specs, columnIndices, maxRows, handle.Workbook.IsDate1904, rows); return NativeStatus.Ok; } catch (Exception exception) @@ -165,22 +134,12 @@ internal string? FaultMessage } } - internal void Fault(string message) + private int Fail(string message) { - if (_disposed || _faulted) - { - return; - } _faulted = true; _faultMessage = message; - _handle.ReleaseLiveSession(this); ReleaseRows(); - } - - private int Fail(string message) - { - Fault(message); - NativeApi.SetLastError(_faultMessage!); + NativeApi.SetLastError(message); return NativeStatus.Error; } @@ -198,9 +157,7 @@ public void Dispose() return; } _disposed = true; - _handle.ReleaseLiveSession(this); ReleaseRows(); - GC.KeepAlive(_handle); } } } diff --git a/src/ExcelReader.Native/Typed/TypedApi.cs b/src/ExcelReader.Native/Typed/TypedApi.cs index 5c582568..928d40f9 100644 --- a/src/ExcelReader.Native/Typed/TypedApi.cs +++ b/src/ExcelReader.Native/Typed/TypedApi.cs @@ -13,15 +13,15 @@ namespace ExcelReader.Native.Typed { internal static unsafe partial class TypedApi { - internal static int ParseTyped(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, out NativeTable table) + internal static int ParseTyped(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, out NativeTable table) { - return ParseTypedTable(handle, specs, headerRow, 1, "xl_parse_typed", out table); + return ParseTypedTable(handle, sheet, specs, headerRow, 1, out table); } - private static int ParseSequential(NativeHandle? handle, NativeColumnSpec[] specs, int headerRow, string cause, out NativeTable table) + private static int ParseSequential(NativeHandle? handle, int sheet, NativeColumnSpec[] specs, int headerRow, out NativeTable table) { table = default; - int status = TypedParseSession.OpenTransient(handle, specs, headerRow, cause, out TypedParseSession? session); + int status = TypedParseSession.Open(handle, sheet, specs, headerRow, maxRows: 0, out TypedParseSession? session); if (status != NativeStatus.Ok) { return status; diff --git a/src/ExcelReader.Native/include/excelreader.def b/src/ExcelReader.Native/include/excelreader.def index 63b1bec4..0e8f3cc6 100644 --- a/src/ExcelReader.Native/include/excelreader.def +++ b/src/ExcelReader.Native/include/excelreader.def @@ -16,13 +16,11 @@ EXPORTS xl_read_all_blob xl_free_rows xl_parse_typed - xl_parse_typed_ex xl_free_table xl_typed_reader_open xl_typed_reader_next xl_typed_reader_close xl_parse_arrow - xl_parse_arrow_ex xl_parse_arrow_stream xl_write_typed xl_write_typed_to_memory @@ -30,7 +28,6 @@ EXPORTS xl_encrypt_package xl_encrypt_package_to_memory xl_infer_schema - xl_infer_schema_ex xl_free_schema xl_open_write_handle xl_open_write_handle_to_memory diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index c9c670eb..4136ed48 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -16,7 +16,7 @@ extern "C" { #define XL_STATUS_PASSWORD_REQUIRED (-6) /* the workbook is encrypted and no password was supplied */ #define XL_STATUS_PASSWORD_INCORRECT (-7) /* the supplied password did not match the workbook's verifier */ -#define XL_ABI_VERSION 5 +#define XL_ABI_VERSION 6 #define XL_FORMAT_AUTO 0 /* sniffs XLS/XLSX/XLSB; does NOT detect CSV */ #define XL_FORMAT_XLS 1 @@ -147,20 +147,19 @@ typedef struct xl_table { xl_column* columns; } xl_table; -int32_t xl_parse_typed(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, - int32_t header_row, xl_table* out_table); - -/* xl_parse_typed with a thread count. degree_of_parallelism: 0 = processor count, 1 = sequential, - n = up to n threads; negative is XL_INVALID_ARGUMENT. Only a CSV handle is read in parallel. Any - other format, a CSV too small to split, or a CSV opened with a non-UTF-8 encoding is read - sequentially. The table equals xl_parse_typed's in every case. */ -int32_t xl_parse_typed_ex(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, - int32_t header_row, int32_t degree_of_parallelism, xl_table* out_table); +/* sheet is a zero-based index: negative is XL_INVALID_ARGUMENT, at or past xl_sheet_count is XL_ERROR. + degree_of_parallelism: 0 = processor count, 1 = sequential, n = up to n threads; negative is + XL_INVALID_ARGUMENT. Only a CSV handle is read in parallel. Any other format, a CSV too small to + split, or a CSV opened with a non-UTF-8 encoding is read sequentially; the table is the same either + way. Calls on one handle may run on several threads at once. */ +int32_t xl_parse_typed(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, + int32_t header_row, int32_t degree_of_parallelism, xl_table* out_table); void xl_free_table(xl_table* table); - -int32_t xl_typed_reader_open(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, +/* The reader owns its position in the sheet. It keeps working after xl_close on its workbook, and any + number of readers may be open on one workbook. Use one reader from one thread at a time. */ +int32_t xl_typed_reader_open(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int64_t max_rows, xl_typed_reader** out_reader); int32_t xl_typed_reader_next(xl_typed_reader* reader, xl_table* out_table); @@ -215,16 +214,14 @@ typedef struct xl_inferred_schema { int32_t column_count; } xl_inferred_schema; -int32_t xl_infer_schema(xl_workbook* handle, int32_t header_row, int32_t sample_size, xl_inferred_schema* out_schema); - #define XL_INFER_PARSE_TEXT 1 -/* xl_infer_schema with flags. XL_INFER_PARSE_TEXT also types cells that hold text (a CSV's fields, - numbers stored as text): integers, decimals, true/false and ISO dates or date-times, when the text - has exactly that shape. Leading-zero codes such as 00123 and scientific notation such as 12E4 stay text. flags == 0 behaves exactly like - xl_infer_schema. Any unknown bit is XL_INVALID_ARGUMENT. Free the result with xl_free_schema. */ -int32_t xl_infer_schema_ex(xl_workbook* handle, int32_t header_row, int32_t sample_size, - int32_t flags, xl_inferred_schema* out_schema); +/* flags: 0, or XL_INFER_PARSE_TEXT to also type cells that hold text (a CSV's fields, numbers stored + as text): integers, decimals, true/false and ISO dates or date-times, when the text has exactly that + shape. Leading-zero codes such as 00123 and scientific notation such as 12E4 stay text. Any unknown + bit is XL_INVALID_ARGUMENT. Free the result with xl_free_schema. */ +int32_t xl_infer_schema(xl_workbook* handle, int32_t sheet, int32_t header_row, int32_t sample_size, + int32_t flags, xl_inferred_schema* out_schema); void xl_free_schema(xl_inferred_schema* schema); diff --git a/src/ExcelReader.Native/include/excelreader_arrow.h b/src/ExcelReader.Native/include/excelreader_arrow.h index bf52ed50..761c387f 100644 --- a/src/ExcelReader.Native/include/excelreader_arrow.h +++ b/src/ExcelReader.Native/include/excelreader_arrow.h @@ -62,15 +62,13 @@ struct ArrowArrayStream { #endif /* ARROW_C_STREAM_INTERFACE */ -int32_t xl_parse_arrow(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, - int32_t header_row, struct ArrowArray* out_array, struct ArrowSchema* out_schema); +/* Same sheet and degree_of_parallelism contract as xl_parse_typed. */ +int32_t xl_parse_arrow(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, + int32_t header_row, int32_t degree_of_parallelism, + struct ArrowArray* out_array, struct ArrowSchema* out_schema); -/* xl_parse_arrow with a thread count; same contract as xl_parse_typed_ex. */ -int32_t xl_parse_arrow_ex(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, - int32_t header_row, int32_t degree_of_parallelism, - struct ArrowArray* out_array, struct ArrowSchema* out_schema); - -int32_t xl_parse_arrow_stream(xl_workbook* handle, const xl_column_spec* specs, int32_t spec_count, +/* The stream owns its position in the sheet and keeps working after xl_close on its workbook. */ +int32_t xl_parse_arrow_stream(xl_workbook* handle, int32_t sheet, const xl_column_spec* specs, int32_t spec_count, int32_t header_row, int64_t max_rows, struct ArrowArrayStream* out_stream); diff --git a/tests/ExcelReader.Benchmarks/Native/ChunkedParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Native/ChunkedParseBenchmark.cs index 03986ac5..16cd9bc6 100644 --- a/tests/ExcelReader.Benchmarks/Native/ChunkedParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Native/ChunkedParseBenchmark.cs @@ -50,7 +50,7 @@ private NativeHandle OpenWorkbook() public long WholeSheet() { using NativeHandle handle = OpenWorkbook(); - int status = TypedApi.ParseTyped(handle, Specs(), headerRow: 1, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, Specs(), headerRow: 1, out NativeTable table); if (status != NativeStatus.Ok) { throw new InvalidOperationException($"parse failed with status {status}"); @@ -68,7 +68,7 @@ public long WholeSheet() private long Drain(long maxRows) { using NativeHandle handle = OpenWorkbook(); - int status = TypedApi.OpenTypedReader(handle, Specs(), headerRow: 1, maxRows, out nint reader); + int status = TypedApi.OpenTypedReader(handle, 0, Specs(), headerRow: 1, maxRows, out nint reader); if (status != NativeStatus.Ok) { throw new InvalidOperationException($"open reader failed with status {status}"); diff --git a/tests/ExcelReader.Benchmarks/Native/NativeTypedParseBenchmark.cs b/tests/ExcelReader.Benchmarks/Native/NativeTypedParseBenchmark.cs index 670d3a39..4da754b4 100644 --- a/tests/ExcelReader.Benchmarks/Native/NativeTypedParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Native/NativeTypedParseBenchmark.cs @@ -44,7 +44,7 @@ public void Setup() public long ParseTyped() { using NativeHandle handle = Open(); - int status = TypedApi.ParseTyped(handle, Schema, HeaderRow, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, Schema, HeaderRow, out NativeTable table); try { VerifyParsed(status, table); @@ -60,7 +60,7 @@ public long ParseTyped() public long ParseArrow() { using NativeHandle handle = Open(); - int status = ArrowApi.ParseArrow(handle, Schema, HeaderRow, out ArrowArray array, out ArrowSchema schema); + int status = ArrowApi.ParseArrow(handle, 0, Schema, HeaderRow, out ArrowArray array, out ArrowSchema schema); if (status != NativeStatus.Ok) { throw new InvalidOperationException($"xl_parse_arrow failed with status {status}."); diff --git a/tests/ExcelReader.NativePgoTrainer/Program.cs b/tests/ExcelReader.NativePgoTrainer/Program.cs index 8881a82c..046166da 100644 --- a/tests/ExcelReader.NativePgoTrainer/Program.cs +++ b/tests/ExcelReader.NativePgoTrainer/Program.cs @@ -49,7 +49,7 @@ private static int Main() int status = ReadApi.OpenMemory(bytes, format, out NativeHandle? handle); if (status == NativeStatus.Ok) { - status = TypedApi.ParseTyped(handle, Specs, headerRow: 1, out NativeTable table); + status = TypedApi.ParseTyped(handle, 0, Specs, headerRow: 1, out NativeTable table); TypedApi.FreeTable(ref table); ReadApi.Close(handle); } diff --git a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs index 4778cc96..f8b39ab7 100644 --- a/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs +++ b/tests/ExcelReader.Tests/Arrow/ArrowConversionTests.cs @@ -230,7 +230,7 @@ public void ToArrowRecordBatch_Should_Match_The_Native_Arrow_Path_On_RealExcel() }; } - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray nativeArray, out ArrowSchema nativeSchema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray nativeArray, out ArrowSchema nativeSchema)); try { Assert.Equal(managedBatch.Length, nativeArray.Length); diff --git a/tests/ExcelReader.Tests/Native/ArrowStreamTests.cs b/tests/ExcelReader.Tests/Native/ArrowStreamTests.cs index c993bdf1..9e797667 100644 --- a/tests/ExcelReader.Tests/Native/ArrowStreamTests.cs +++ b/tests/ExcelReader.Tests/Native/ArrowStreamTests.cs @@ -20,7 +20,7 @@ private static ArrowArrayStream Open(long maxRows, out NativeHandle live) Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); live = handle!; Assert.Equal(NativeStatus.Ok, - ArrowApi.OpenArrowStream(live, Specs(), headerRow: 0, maxRows, out ArrowArrayStream stream)); + ArrowApi.OpenArrowStream(live, 0, Specs(), headerRow: 0, maxRows, out ArrowArrayStream stream)); return stream; } @@ -117,7 +117,7 @@ public void Release_Should_Be_Safe_Mid_Stream_And_Idempotent() InvokeRelease(ref stream); Assert.Equal(NativeStatus.Ok, - ArrowApi.OpenArrowStream(live, Specs(), headerRow: 0, maxRows: 1, out ArrowArrayStream reopened)); + ArrowApi.OpenArrowStream(live, 0, Specs(), headerRow: 0, maxRows: 1, out ArrowArrayStream reopened)); Assert.Equal(0, InvokeGetNext(ref reopened, out ArrowArray fromReopened)); Assert.NotEqual(IntPtr.Zero, fromReopened.Release); ReleaseArray(ref fromReopened); @@ -157,7 +157,7 @@ private static List ReadTallThroughStream(string path, long maxRows, ou Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; Assert.Equal(NativeStatus.Ok, - ArrowApi.OpenArrowStream(live, TallSpecs(), headerRow: 1, maxRows, out ArrowArrayStream stream)); + ArrowApi.OpenArrowStream(live, 0, TallSpecs(), headerRow: 1, maxRows, out ArrowArrayStream stream)); List rows = []; batchCount = 0; @@ -289,7 +289,7 @@ public void OpenArrowStream_Should_Reject_A_Negative_Batch_Size_And_Zero_The_Str Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); using NativeHandle live = handle!; - int status = ArrowApi.OpenArrowStream(live, Specs(), headerRow: 0, maxRows: -1, out ArrowArrayStream stream); + int status = ArrowApi.OpenArrowStream(live, 0, Specs(), headerRow: 0, maxRows: -1, out ArrowArrayStream stream); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(IntPtr.Zero, stream.Release); @@ -314,7 +314,7 @@ public void GetNext_Should_Latch_A_Conversion_Failure_With_A_Message() using NativeHandle live = handle!; NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64 }]; Assert.Equal(NativeStatus.Ok, - ArrowApi.OpenArrowStream(live, specs, headerRow: 1, maxRows: 2, out ArrowArrayStream stream)); + ArrowApi.OpenArrowStream(live, 0, specs, headerRow: 1, maxRows: 2, out ArrowArrayStream stream)); try { Assert.Equal(0, InvokeGetNext(ref stream, out ArrowArray good)); @@ -343,7 +343,7 @@ public void GetNext_Should_Latch_A_Conversion_Failure_With_A_Message() } [Fact] - public void GetNext_Should_Fail_Cleanly_After_The_Workbook_Is_Closed() + public void GetNext_Should_Keep_Reading_After_The_Workbook_Is_Closed() { ArrowArrayStream stream = Open(maxRows: 1, out NativeHandle live); try @@ -354,14 +354,9 @@ public void GetNext_Should_Fail_Cleanly_After_The_Workbook_Is_Closed() live.Dispose(); - Assert.NotEqual(0, InvokeGetNext(ref stream, out ArrowArray after)); - Assert.Equal(IntPtr.Zero, after.Release); - string? latched = Marshal.PtrToStringUTF8(InvokeGetLastError(ref stream)); - Assert.False(string.IsNullOrEmpty(latched)); - - Assert.NotEqual(0, InvokeGetNext(ref stream, out ArrowArray again)); - Assert.Equal(IntPtr.Zero, again.Release); - Assert.Equal(latched, Marshal.PtrToStringUTF8(InvokeGetLastError(ref stream))); + Assert.Equal(0, InvokeGetNext(ref stream, out ArrowArray after)); + Assert.NotEqual(IntPtr.Zero, after.Release); + ReleaseArray(ref after); } finally { diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Arrow.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Arrow.cs index 3b037597..dc1ad286 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Arrow.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Arrow.cs @@ -56,7 +56,7 @@ public void ParseArrow_Should_Return_A_Struct_Array_With_A_Matching_Schema() new() { Names = ["name"], Type = NativeColumnType.String }, new() { Names = ["qty"], Type = NativeColumnType.Int64 }, ]; - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); try { Assert.Equal("+s", Marshal.PtrToStringUTF8(schema.Format)); @@ -109,7 +109,7 @@ public void ParseArrow_Should_Bit_Pack_Bool_Columns() { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["flag"], Type = NativeColumnType.Bool }]; - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); try { ArrowArray column = ArrowChildArray(array, 0); @@ -146,7 +146,7 @@ public void ParseArrow_Should_Report_Null_Count_From_The_Validity_Bitmap() { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64, Nullable = true }]; - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); try { ArrowArray column = ArrowChildArray(array, 0); @@ -176,7 +176,7 @@ public void ParseArrow_Should_Propagate_A_Non_Nullable_Conversion_Failure() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64 }]; - int status = ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema); + int status = ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema); Assert.Equal(NativeStatus.Error, status); Assert.Equal(IntPtr.Zero, array.Release); @@ -193,7 +193,7 @@ public void ParseArrow_Should_Propagate_A_Non_Nullable_Conversion_Failure() public void ParseArrow_Should_Reject_A_Null_Handle() { NativeColumnSpec[] specs = [new() { Index = 0, Type = NativeColumnType.String }]; - Assert.Equal(NativeStatus.InvalidHandle, ArrowApi.ParseArrow(null, specs, headerRow: 1, out _, out _)); + Assert.Equal(NativeStatus.InvalidHandle, ArrowApi.ParseArrow(null, 0, specs, headerRow: 1, out _, out _)); } [Fact] @@ -205,7 +205,7 @@ public void ReleaseArrowArray_And_ReleaseArrowSchema_Are_Idempotent() { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["name"], Type = NativeColumnType.String }]; - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, specs, headerRow: 1, out ArrowArray array, out ArrowSchema schema)); ExercisedReleaseArrow(ref array, ref schema); ExercisedReleaseArrow(ref array, ref schema); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs index 3573ae96..b329b507 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs @@ -24,7 +24,7 @@ public void InferSchema_Should_Type_Text_Cells_When_ParseText_Is_Set() Assert.Equal(NativeStatus.Ok, OpenPath(FixtureCsvPath(), NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, ReadApi.InferParseText, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, ReadApi.InferParseText, out NativeInferredSchema schema)); (string? Name, int Index, int Type, bool Nullable)[] columns; try { @@ -39,7 +39,7 @@ public void InferSchema_Should_Type_Text_Cells_When_ParseText_Is_Set() Assert.Equal([s, s, s, s, s, d, i, d, i, f, f, f, f, f], Array.ConvertAll(columns, c => c.Type)); NativeColumnSpec[] specs = Array.ConvertAll(columns, c => new NativeColumnSpec { Names = [c.Name!], Type = c.Type, Nullable = c.Nullable }); - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { Assert.Equal(65_535, table.RowCount); @@ -62,8 +62,8 @@ public void InferSchema_Should_Match_The_Flagless_Result_When_Flags_Are_Zero() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, out NativeInferredSchema plain)); - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema flagged)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema plain)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema flagged)); try { Assert.Equal(DecodeSchema(plain), DecodeSchema(flagged)); @@ -87,7 +87,7 @@ public void InferSchema_Should_Reject_Unknown_Flags() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, 2, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 2, out NativeInferredSchema schema)); Assert.Equal(IntPtr.Zero, schema.Columns); Assert.Contains("flags", NativeApi.LastErrorText(), StringComparison.Ordinal); } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs index 5748d38a..9cdc7a9c 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs @@ -48,7 +48,7 @@ private static (int Status, NativeTable Table, string Error, bool Parallel) Pars Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(csv, NativeFormat.Csv, out NativeHandle? handle, options)); try { - int status = TypedApi.ParseTypedTable(handle, specs, headerRow, dop, "test", out NativeTable table, chunkSize); + int status = TypedApi.ParseTypedTable(handle, 0, specs, headerRow, dop, out NativeTable table, chunkSize); return (status, table, NativeApi.LastErrorText(), TypedApi.LastParseRanInParallel); } finally @@ -170,7 +170,7 @@ public void ParseTypedTable_Should_Free_Every_Block_Exactly_Once_When_A_Parallel TypedApi.FreeOverride = tracker.Free; try { - status = TypedApi.ParseTypedTable(handle, specs, headerRow: 1, 4, "test", out table, SmallChunk); + status = TypedApi.ParseTypedTable(handle, 0, specs, headerRow: 1, 4, out table, SmallChunk); } finally { @@ -284,8 +284,8 @@ public void ParseTypedTable_Should_Read_A_Non_Csv_Workbook_Like_ParseTyped() Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 0, out NativeTable expected)); - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, specs, headerRow: 0, 0, "test", out NativeTable actual)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 0, out NativeTable expected)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, 0, specs, headerRow: 0, 0, out NativeTable actual)); try { Assert.False(TypedApi.LastParseRanInParallel); @@ -331,7 +331,7 @@ public void ParseTypedTable_Should_Read_From_A_File_Without_Moving_The_Row_Curso Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, blob, out _)); } - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, MixedSpecs, headerRow: 1, 4, "test", out NativeTable actual, SmallChunk)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, 0, MixedSpecs, headerRow: 1, 4, out NativeTable actual, SmallChunk)); try { Assert.True(TypedApi.LastParseRanInParallel); @@ -362,33 +362,6 @@ public void ParseTypedTable_Should_Read_From_A_File_Without_Moving_The_Row_Curso } } - [Fact] - public void ParseTypedTable_Should_Fault_A_Live_Chunked_Read_On_The_Same_Handle() - { - byte[] csv = Encoding.UTF8.GetBytes(MixedCsv(MixedRows)); - Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(csv, NativeFormat.Csv, out NativeHandle? handle)); - try - { - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(handle, MixedSpecs, headerRow: 1, 100, out nint reader)); - try - { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, MixedSpecs, headerRow: 1, 4, "test", out NativeTable whole, SmallChunk)); - TypedApi.FreeTable(ref whole); - - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out NativeTable after)); - Assert.Equal(IntPtr.Zero, after.Columns); - } - finally - { - TypedApi.CloseTypedReader(reader); - } - } - finally - { - ReadApi.Close(handle); - } - } - [Fact] public void ParseArrow_Should_Accept_A_Degree_Of_Parallelism() { @@ -396,7 +369,7 @@ public void ParseArrow_Should_Accept_A_Degree_Of_Parallelism() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(csv, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, MixedSpecs, headerRow: 1, 0, "test", out ArrowArray array, out ArrowSchema schema)); + Assert.Equal(NativeStatus.Ok, ArrowApi.ParseArrow(handle, 0, MixedSpecs, headerRow: 1, 0, out ArrowArray array, out ArrowSchema schema)); try { Assert.Equal(MixedRows, array.Length); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index ec525445..66402c9f 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -1263,7 +1263,7 @@ public void InferSchema_Should_Guess_Types_From_Sampled_Cells() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema schema)); try { Assert.Equal(6, schema.ColumnCount); @@ -1295,7 +1295,7 @@ public void InferSchema_Should_Resolve_By_Index_When_Header_Row_Is_Zero() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 0, sampleSize: 100, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 0, sampleSize: 100, 0, out NativeInferredSchema schema)); try { foreach ((string? name, _, _, _) in DecodeSchema(schema)) @@ -1321,7 +1321,7 @@ public void InferSchema_Should_Reject_A_Negative_Header_Row() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, headerRow: -1, sampleSize: 100, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, 0, headerRow: -1, sampleSize: 100, 0, out NativeInferredSchema schema)); Assert.Equal(IntPtr.Zero, schema.Columns); } finally @@ -1339,7 +1339,7 @@ public void InferSchema_Should_Reject_A_NonPositive_Sample_Size(int sampleSize) Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, headerRow: 1, sampleSize, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.InvalidArgument, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize, 0, out NativeInferredSchema schema)); Assert.Equal(IntPtr.Zero, schema.Columns); } finally @@ -1351,7 +1351,7 @@ public void InferSchema_Should_Reject_A_NonPositive_Sample_Size(int sampleSize) [Fact] public void InferSchema_Should_Reject_A_Null_Handle() { - Assert.Equal(NativeStatus.InvalidHandle, ReadApi.InferSchema(null, headerRow: 1, sampleSize: 100, out _)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.InferSchema(null, 0, headerRow: 1, sampleSize: 100, 0, out _)); } [Fact] @@ -1361,7 +1361,7 @@ public void InferSchema_Should_Report_An_Error_When_The_Sheet_Has_Fewer_Rows_Tha Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - int status = ReadApi.InferSchema(handle, headerRow: 5, sampleSize: 100, out NativeInferredSchema schema); + int status = ReadApi.InferSchema(handle, 0, headerRow: 5, sampleSize: 100, 0, out NativeInferredSchema schema); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(IntPtr.Zero, schema.Columns); @@ -1384,7 +1384,7 @@ public void InferSchema_Should_Not_Disturb_The_Row_Cursor() { Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, headerRow: 1, sampleSize: 100, out NativeInferredSchema schema)); + Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema schema)); ReadApi.FreeSchema(ref schema); byte[] buffer = new byte[4096]; diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs new file mode 100644 index 00000000..689b0380 --- /dev/null +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs @@ -0,0 +1,75 @@ +using System.Text; +using ExcelReader.Native; +using ExcelReader.Native.Arrow; +using ExcelReader.Native.Reading; +using ExcelReader.Native.Typed; + +namespace ExcelReader.Tests.Native +{ + public sealed partial class NativeApiTests + { + private static NativeColumnSpec[] FirstColumnAsText() + { + return [new() { Index = 0, Type = NativeColumnType.String, Nullable = true }]; + } + + private static async Task OpenTwoSheetsAsync() + { + await using MemoryStream ms = await TypedWorkbook.BuildMultiSheetAsync( + ("First", new object?[][] { ["a1"], ["a2"] }), + ("Second", new object?[][] { ["b1"], ["b2"], ["b3"] })); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); + return handle!; + } + + [Fact] + public async Task ParseTyped_Should_Read_The_Sheet_At_The_Given_Index() + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 1, FirstColumnAsText(), headerRow: 0, out NativeTable table)); + try + { + Assert.Equal(["b1", "b2", "b3"], DecodeStringColumn(ColumnAt(table, 0))); + } + finally + { + TypedApi.FreeTable(ref table); + } + } + + [Theory] + [InlineData(-1, NativeStatus.InvalidArgument)] + [InlineData(2, NativeStatus.Error)] + public async Task EverySheetRead_Should_Reject_A_Sheet_Index_Out_Of_Range(int sheet, int expected) + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + + Assert.Equal(expected, TypedApi.ParseTyped(handle, sheet, FirstColumnAsText(), headerRow: 0, out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); + Assert.Equal(expected, TypedApi.OpenTypedReader(handle, sheet, FirstColumnAsText(), headerRow: 0, maxRows: 0, out nint reader)); + Assert.Equal(0, reader); + Assert.Equal(expected, ArrowApi.OpenArrowStream(handle, sheet, FirstColumnAsText(), headerRow: 0, maxRows: 0, out _)); + Assert.Equal(expected, ArrowApi.ParseArrow(handle, sheet, FirstColumnAsText(), headerRow: 0, out _, out _)); + Assert.Equal(expected, ReadApi.InferSchema(handle, sheet, headerRow: 0, sampleSize: 10, flags: 0, out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); + } + + [Fact] + public void ParseTyped_Should_Reject_Sheet_One_Of_A_Csv_Even_When_Asked_To_Run_In_Parallel() + { + StringBuilder csv = new("id\n"); + for (int i = 0; i < 200_000; i++) + { + csv.Append(i).Append('\n'); + } + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(Encoding.UTF8.GetBytes(csv.ToString()), NativeFormat.Csv, out NativeHandle? handle)); + using NativeHandle live = handle!; + + int status = TypedApi.ParseTypedTable(live, 1, FirstColumnAsText(), headerRow: 1, degreeOfParallelism: 0, out NativeTable table); + + Assert.Equal(NativeStatus.Error, status); + Assert.Equal(IntPtr.Zero, table.Columns); + } + } +} diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs index 708aac04..d183ea98 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs @@ -25,7 +25,7 @@ public void ParseTyped_Should_Return_Typed_Columns_By_Name() new() { Names = ["active"], Type = NativeColumnType.Bool }, new() { Names = ["joined"], Type = NativeColumnType.Date }, ]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { Assert.Equal(5, table.ColumnCount); @@ -75,7 +75,7 @@ public void ParseTyped_Should_Resolve_By_Index_When_Header_Row_Is_Zero() new() { Index = 0, Type = NativeColumnType.Int64 }, new() { Index = 1, Type = NativeColumnType.Int64 }, ]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 0, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 0, out NativeTable table)); try { Assert.Equal(2, table.RowCount); @@ -111,7 +111,7 @@ public void ParseTyped_Should_Compute_Time_And_Timestamp_As_Microseconds() new() { Names = ["at"], Type = NativeColumnType.Time }, new() { Names = ["logged"], Type = NativeColumnType.Timestamp }, ]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { long[] time = new long[1]; @@ -183,7 +183,7 @@ public void ParseTyped_Should_Reject_A_Blank_Column_Name() NativeColumnSpec[] specs = [new() { Names = [" "], Type = NativeColumnType.String }]; try { - Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); Assert.Equal(IntPtr.Zero, table.Columns); Span buffer = stackalloc byte[256]; @@ -222,7 +222,7 @@ public void ParseTyped_Validity_Bitmap_Should_Backfill_Rows_Before_The_First_Nul { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64, Nullable = true }]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { bool[] expected = new bool[rowCount]; @@ -261,7 +261,7 @@ public void ParseTyped_Validity_Bitmap_Should_Survive_Byte_Boundaries() { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64, Nullable = true }]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { NativeColumn column = ColumnAt(table, 0); @@ -304,7 +304,7 @@ public void ParseTyped_Should_Mark_Failed_Nullable_Conversions_In_The_Validity_B { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64, Nullable = true }]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); try { NativeColumn column = ColumnAt(table, 0); @@ -337,7 +337,7 @@ public void ParseTyped_Should_Fail_For_A_Non_Nullable_Conversion_Failure() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["qty"], Type = NativeColumnType.Int64, Nullable = false }]; - int status = TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table); Assert.Equal(NativeStatus.Error, status); Assert.Equal(IntPtr.Zero, table.Columns); @@ -359,7 +359,7 @@ public void ParseTyped_Should_Reject_A_Name_Based_Spec_When_Header_Row_Is_Zero() try { NativeColumnSpec[] specs = [new() { Names = ["anything"], Type = NativeColumnType.String }]; - Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, specs, headerRow: 0, out NativeTable table)); + Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, 0, specs, headerRow: 0, out NativeTable table)); Assert.Equal(IntPtr.Zero, table.Columns); } finally @@ -378,7 +378,7 @@ public void ParseTyped_Should_Reject_An_Unmatched_Header_Name() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["does-not-exist"], Type = NativeColumnType.String }]; - int status = TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(IntPtr.Zero, table.Columns); @@ -399,7 +399,7 @@ public void ParseTyped_Should_Resolve_The_First_Candidate_Name_Present_In_The_He { Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["does-not-exist", "quantity"], Type = NativeColumnType.Int64 }]; - int status = TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table); Assert.Equal(NativeStatus.Ok, status); Assert.Equal(1, table.RowCount); long value = Marshal.ReadInt64(ColumnAt(table, 0).Values); @@ -423,7 +423,7 @@ public void ParseTyped_Should_Fail_With_A_Message_Listing_Every_Candidate_When_N Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); NativeColumnSpec[] specs = [new() { Names = ["nope", "still-nope"], Type = NativeColumnType.Int64 }]; - int status = TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table); + int status = TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(IntPtr.Zero, table.Columns); @@ -446,7 +446,7 @@ public void ParseTyped_Should_Reject_Zero_Specs() Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, [], headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.InvalidArgument, TypedApi.ParseTyped(handle, 0, [], headerRow: 1, out NativeTable table)); Assert.Equal(IntPtr.Zero, table.Columns); } finally @@ -459,7 +459,7 @@ public void ParseTyped_Should_Reject_Zero_Specs() public void ParseTyped_Should_Reject_A_Null_Handle() { NativeColumnSpec[] specs = [new() { Index = 0, Type = NativeColumnType.String }]; - Assert.Equal(NativeStatus.InvalidHandle, TypedApi.ParseTyped(null, specs, headerRow: 1, out _)); + Assert.Equal(NativeStatus.InvalidHandle, TypedApi.ParseTyped(null, 0, specs, headerRow: 1, out _)); } [Fact] @@ -475,7 +475,7 @@ public void ParseTyped_Should_Not_Disturb_The_Row_Cursor() Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); NativeColumnSpec[] specs = [new() { Names = ["name"], Type = NativeColumnType.String }]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable table)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); TypedApi.FreeTable(ref table); byte[] buffer = new byte[4096]; @@ -552,7 +552,7 @@ private static int ParseTypedWithTracker(BlockTracker tracker, out NativeTable t TypedApi.FreeOverride = tracker.Free; try { - return TypedApi.ParseTyped(handle, specs, headerRow: 1, out table); + return TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out table); } finally { diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs index 79589575..5c4cd832 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Writer.cs @@ -538,7 +538,7 @@ public void WriteTyped_Should_Round_Trip_Through_ParseTyped(int format, string e Assert.Equal(NativeStatus.Ok, OpenPath(path, format, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(2, read.RowCount); @@ -583,7 +583,7 @@ public void WriteTypedToMemory_Should_Round_Trip_Through_OpenMemory(int format) Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, format, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(2, read.RowCount); @@ -666,7 +666,7 @@ public void WriteTyped_Should_Round_Trip_Bools_Through_ParseTyped(int format, st Assert.Equal(NativeStatus.Ok, OpenPath(path, format, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(3, read.RowCount); @@ -710,7 +710,7 @@ public void WriteTyped_Should_Round_Trip_Doubles_Through_ParseTyped(int format, Assert.Equal(NativeStatus.Ok, OpenPath(path, format, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(2, read.RowCount); @@ -755,7 +755,7 @@ public void WriteTyped_Should_Round_Trip_Strings_And_Nulls() try { NativeColumnSpec[] readSpecs = [new() { Names = ["name"], Type = NativeColumnType.String, Nullable = true }]; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, readSpecs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, readSpecs, headerRow: 1, out NativeTable read)); try { Assert.Equal(4, read.RowCount); @@ -793,7 +793,7 @@ public void WriteTyped_Should_Round_Trip_A_Validity_Bitmap_With_Nulls_At_Both_En Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(3, read.RowCount); @@ -834,7 +834,7 @@ public void WriteTyped_Should_Round_Trip_A_String_Column_That_Is_Entirely_Empty( Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { Assert.Equal(2, read.RowCount); @@ -881,7 +881,7 @@ public void WriteTyped_Should_Round_Trip_Every_Temporal_Type() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { int[] days = new int[1]; @@ -936,7 +936,7 @@ public void WriteTyped_Should_Carry_Date1904_Into_The_Written_Xlsb() Assert.Equal(NativeStatus.Ok, ReadApi.IsDate1904(handle, out int flag)); Assert.Equal(1, flag); - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, specs, headerRow: 1, out NativeTable read)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable read)); try { int[] days = new int[1]; diff --git a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs index 29bdc52b..818871f8 100644 --- a/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs +++ b/tests/ExcelReader.Tests/Native/SessionLifetimeTests.cs @@ -9,12 +9,8 @@ namespace ExcelReader.Tests.Native { /// - /// The one-live-session rule, from the outside. A xl_typed_reader/Arrow stream is the only - /// thing in this ABI that holds an IExcelRowEnumerator open ACROSS calls, and - /// the native handle serves one row cursor at a time — so every - /// other read on the same workbook either has to be refused up front or has to invalidate the - /// live session loudly. These tests pin both halves; without the interlock they fail by returning - /// XL_OK/XL_EOF with silently truncated data. + /// A typed reader and an Arrow stream each own their enumerator, so reads on one workbook do not + /// disturb one another and outlive the workbook handle. /// public sealed class SessionLifetimeTests { @@ -22,9 +18,6 @@ public sealed class SessionLifetimeTests private const int SmallRowCount = 500; - private const int InterleaveRowCount = 200_000; - private const long InterleaveBatchSize = 1000; - private static NativeColumnSpec[] IdSpecs() { return [new() { Names = ["id"], Type = NativeColumnType.Int64 }]; @@ -60,124 +53,48 @@ private static long RowsIn(NativeTable table) } - [Fact] - public void OpenTypedReader_Should_Refuse_A_Second_Live_Reader_On_The_Same_Workbook() - { - Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint first)); - - int status = TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint second); - - Assert.Equal(NativeStatus.Error, status); - Assert.Equal(0, second); - Assert.NotEmpty(NativeApi.LastErrorText()); - - TypedApi.CloseTypedReader(first); - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint third)); - TypedApi.CloseTypedReader(third); - } - - [Fact] - public void OpenArrowStream_Should_Refuse_A_Stream_While_A_Typed_Reader_Is_Open() - { - Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint reader)); - - int status = ArrowApi.OpenArrowStream(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out ArrowArrayStream stream); - - Assert.Equal(NativeStatus.Error, status); - Assert.Equal(IntPtr.Zero, stream.Release); - Assert.NotEmpty(NativeApi.LastErrorText()); - TypedApi.CloseTypedReader(reader); - } - - [Fact] - public void OpenTypedReader_Should_Refuse_A_Reader_While_An_Arrow_Stream_Is_Open() - { - Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, - ArrowApi.OpenArrowStream(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out ArrowArrayStream stream)); - - int status = TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint reader); - - Assert.Equal(NativeStatus.Error, status); - Assert.Equal(0, reader); - - ReleaseStream(ref stream); - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, FirstColumnSpecs(), headerRow: 0, maxRows: 1, out nint after)); - TypedApi.CloseTypedReader(after); - } - - - [Fact] - public void ParseTyped_Should_Fault_A_Live_Reader_Instead_Of_Truncating_It() + private static long Drain(nint reader) { - string path = WriteCsv(InterleaveRowCount); - try + long total = 0; + while (true) { - Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); - using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, - TypedApi.OpenTypedReader(live, IdSpecs(), headerRow: 1, InterleaveBatchSize, out nint reader)); - try + int status = TypedApi.NextTypedBatch(reader, out NativeTable batch); + if (status == NativeStatus.Eof) { - Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable batch)); - long fromReader = RowsIn(batch); - TypedApi.FreeTable(ref batch); - Assert.Equal(InterleaveBatchSize, fromReader); - - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, IdSpecs(), headerRow: 1, out NativeTable whole)); - long interleaved = RowsIn(whole); - TypedApi.FreeTable(ref whole); - Assert.Equal(InterleaveRowCount, interleaved); - - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out NativeTable after)); - Assert.Equal(IntPtr.Zero, after.Columns); - Assert.NotEmpty(NativeApi.LastErrorText()); - } - finally - { - TypedApi.CloseTypedReader(reader); + return total; } - } - finally - { - File.Delete(path); + Assert.Equal(NativeStatus.Ok, status); + total += batch.RowCount; + TypedApi.FreeTable(ref batch); } } - - private static void AssertFaultsTheLiveReader(Action interleaved) + [Fact] + public void Two_Typed_Readers_On_One_Workbook_Should_Both_Read_Every_Row() { string path = WriteCsv(SmallRowCount); try { Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, - TypedApi.OpenTypedReader(live, IdSpecs(), headerRow: 1, maxRows: 10, out nint reader)); + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, 0, IdSpecs(), headerRow: 1, maxRows: 10, out nint first)); + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, 0, IdSpecs(), headerRow: 1, maxRows: 7, out nint second)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable first)); - TypedApi.FreeTable(ref first); - - interleaved(live); - - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out NativeTable after)); - Assert.Equal(IntPtr.Zero, after.Columns); - string latched = NativeApi.LastErrorText(); - Assert.NotEmpty(latched); - - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out NativeTable again)); - Assert.Equal(IntPtr.Zero, again.Columns); - Assert.Equal(latched, NativeApi.LastErrorText()); + Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(first, out NativeTable a)); + Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(second, out NativeTable b)); + long fromFirst = a.RowCount; + long fromSecond = b.RowCount; + TypedApi.FreeTable(ref a); + TypedApi.FreeTable(ref b); + + Assert.Equal(SmallRowCount, fromFirst + Drain(first)); + Assert.Equal(SmallRowCount, fromSecond + Drain(second)); } finally { - TypedApi.CloseTypedReader(reader); + TypedApi.CloseTypedReader(first); + TypedApi.CloseTypedReader(second); } } finally @@ -187,82 +104,27 @@ private static void AssertFaultsTheLiveReader(Action interleaved) } [Fact] - public void ParseArrow_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => - { - Assert.Equal(NativeStatus.Ok, - ArrowApi.ParseArrow(live, IdSpecs(), headerRow: 1, out ArrowArray array, out ArrowSchema schema)); - ReleaseArray(ref array); - ReleaseSchema(ref schema); - }); - } - - [Fact] - public void NextRow_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(live, Span.Empty, out _))); - } - - [Fact] - public void ReadAllBlob_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(live, Span.Empty, out _))); - } - - [Fact] - public void ReadAllDecoded_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => - { - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(live, out NativeRows rows)); - ReadApi.FreeRows(ref rows); - }); - } - - [Fact] - public void InferSchema_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => - { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(live, headerRow: 1, sampleSize: 8, out NativeInferredSchema schema)); - ReadApi.FreeSchema(ref schema); - }); - } - - [Fact] - public void MoveToSheet_Should_Fault_A_Live_Reader() - { - AssertFaultsTheLiveReader(static live => Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(live, 0))); - } - - - [Fact] - public void Dispose_Should_Fault_A_Live_Reader_And_Latch() + public void ParseTyped_And_An_Arrow_Stream_Should_Leave_A_Live_Reader_Untouched() { string path = WriteCsv(SmallRowCount); try { Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); - NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, - TypedApi.OpenTypedReader(live, IdSpecs(), headerRow: 1, maxRows: 10, out nint reader)); + using NativeHandle live = handle!; + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, 0, IdSpecs(), headerRow: 1, maxRows: 10, out nint reader)); try { Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable first)); + long taken = first.RowCount; TypedApi.FreeTable(ref first); - live.Dispose(); - - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out NativeTable after)); - Assert.Equal(IntPtr.Zero, after.Columns); - string latched = NativeApi.LastErrorText(); - Assert.NotEmpty(latched); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, 0, IdSpecs(), headerRow: 1, out NativeTable whole)); + Assert.Equal(SmallRowCount, whole.RowCount); + TypedApi.FreeTable(ref whole); + Assert.Equal(NativeStatus.Ok, ArrowApi.OpenArrowStream(live, 0, IdSpecs(), headerRow: 1, maxRows: 10, out ArrowArrayStream stream)); + ReleaseStream(ref stream); - Assert.Equal(NativeStatus.Error, TypedApi.NextTypedBatch(reader, out _)); - Assert.Equal(latched, NativeApi.LastErrorText()); + Assert.Equal(SmallRowCount, taken + Drain(reader)); } finally { @@ -275,33 +137,24 @@ public void Dispose_Should_Fault_A_Live_Reader_And_Latch() } } - - [Fact] - public void ParseTyped_Should_Not_Fault_A_Live_Reader_When_Argument_Validation_Fails() + [Theory] + [InlineData(NativeFormat.Csv)] + [InlineData(NativeFormat.Xlsx)] + public void A_Typed_Reader_Opened_But_Not_Yet_Read_Should_Read_Every_Row_After_Its_Workbook_Is_Closed(int format) { - string path = WriteCsv(SmallRowCount); + string path = format == NativeFormat.Csv ? WriteCsv(SmallRowCount) : XlsxFixture; + NativeColumnSpec[] specs = format == NativeFormat.Csv ? IdSpecs() : FirstColumnSpecs(); + int headerRow = format == NativeFormat.Csv ? 1 : 0; + long expected = format == NativeFormat.Csv ? SmallRowCount : 3; try { - Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); - using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, - TypedApi.OpenTypedReader(live, IdSpecs(), headerRow: 1, maxRows: 10, out nint reader)); + Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, format, out NativeHandle? handle)); + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(handle, 0, specs, headerRow, maxRows: 2, out nint reader)); try { - Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable first)); - TypedApi.FreeTable(ref first); - - NativeColumnSpec[] blankNameSpec = [new() { Names = [" "], Type = NativeColumnType.Int64 }]; - int status = TypedApi.ParseTyped(live, blankNameSpec, headerRow: 1, out NativeTable invalid); + Assert.Equal(NativeStatus.Ok, ReadApi.Close(handle)); - Assert.Equal(NativeStatus.InvalidArgument, status); - Assert.Equal(IntPtr.Zero, invalid.Columns); - Assert.NotEmpty(NativeApi.LastErrorText()); - - Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable after)); - long rowsAfter = RowsIn(after); - TypedApi.FreeTable(ref after); - Assert.True(rowsAfter > 0); + Assert.Equal(expected, Drain(reader)); } finally { @@ -310,11 +163,13 @@ public void ParseTyped_Should_Not_Fault_A_Live_Reader_When_Argument_Validation_F } finally { - File.Delete(path); + if (format == NativeFormat.Csv) + { + File.Delete(path); + } } } - [Fact] public void NextTypedBatch_Should_Latch_A_Conversion_Failure() { @@ -324,7 +179,7 @@ public void NextTypedBatch_Should_Latch_A_Conversion_Failure() Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; Assert.Equal(NativeStatus.Ok, - TypedApi.OpenTypedReader(live, IdSpecs(), headerRow: 1, maxRows: 2, out nint reader)); + TypedApi.OpenTypedReader(live, 0, IdSpecs(), headerRow: 1, maxRows: 2, out nint reader)); try { Assert.Equal(NativeStatus.Ok, TypedApi.NextTypedBatch(reader, out NativeTable good)); @@ -361,10 +216,10 @@ public void ParseTyped_Should_Stay_Repeatable_On_The_Same_Workbook() Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, IdSpecs(), headerRow: 1, out NativeTable first)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, 0, IdSpecs(), headerRow: 1, out NativeTable first)); long firstRows = RowsIn(first); TypedApi.FreeTable(ref first); - Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, IdSpecs(), headerRow: 1, out NativeTable second)); + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, 0, IdSpecs(), headerRow: 1, out NativeTable second)); long secondRows = RowsIn(second); TypedApi.FreeTable(ref second); @@ -378,22 +233,10 @@ public void ParseTyped_Should_Stay_Repeatable_On_The_Same_Workbook() } private delegate void ReleaseStreamFn(ref ArrowArrayStream stream); - private delegate void ReleaseArrayFn(ref ArrowArray array); - private delegate void ReleaseSchemaFn(ref ArrowSchema schema); private static void ReleaseStream(ref ArrowArrayStream stream) { Marshal.GetDelegateForFunctionPointer(stream.Release)(ref stream); } - - private static void ReleaseArray(ref ArrowArray array) - { - Marshal.GetDelegateForFunctionPointer(array.Release)(ref array); - } - - private static void ReleaseSchema(ref ArrowSchema schema) - { - Marshal.GetDelegateForFunctionPointer(schema.Release)(ref schema); - } } } diff --git a/tests/ExcelReader.Tests/Native/TypedParseSessionTests.cs b/tests/ExcelReader.Tests/Native/TypedParseSessionTests.cs index e1faa247..786e608b 100644 --- a/tests/ExcelReader.Tests/Native/TypedParseSessionTests.cs +++ b/tests/ExcelReader.Tests/Native/TypedParseSessionTests.cs @@ -20,7 +20,7 @@ private static List ReadInBatches(long maxRows, out int batchCount) Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); using NativeHandle live = handle!; Assert.Equal(NativeStatus.Ok, - TypedApi.TypedParseSession.Open(live, Specs(), headerRow: 0, maxRows, out TypedApi.TypedParseSession? session)); + TypedApi.TypedParseSession.Open(live, 0, Specs(), headerRow: 0, maxRows, out TypedApi.TypedParseSession? session)); using TypedApi.TypedParseSession open = session!; List values = []; @@ -111,7 +111,7 @@ private static List ReadTallInBatches(string path, long maxRows, out in Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; Assert.Equal(NativeStatus.Ok, - TypedApi.TypedParseSession.Open(live, TallSpecs(), headerRow: 1, maxRows, out TypedApi.TypedParseSession? session)); + TypedApi.TypedParseSession.Open(live, 0, TallSpecs(), headerRow: 1, maxRows, out TypedApi.TypedParseSession? session)); using TypedApi.TypedParseSession open = session!; List rows = []; @@ -257,7 +257,7 @@ private static List MeasureBatchByteSizes(string path, long maxRows) Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); using NativeHandle live = handle!; Assert.Equal(NativeStatus.Ok, - TypedApi.TypedParseSession.Open(live, TallSpecs(), headerRow: 1, maxRows, out TypedApi.TypedParseSession? session)); + TypedApi.TypedParseSession.Open(live, 0, TallSpecs(), headerRow: 1, maxRows, out TypedApi.TypedParseSession? session)); using TypedApi.TypedParseSession open = session!; List sizes = []; @@ -315,7 +315,7 @@ public void OpenTypedReader_Should_Reject_A_Negative_Batch_Size() Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); using NativeHandle live = handle!; - int status = TypedApi.OpenTypedReader(live, Specs(), headerRow: 0, maxRows: -1, out nint reader); + int status = TypedApi.OpenTypedReader(live, 0, Specs(), headerRow: 0, maxRows: -1, out nint reader); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(0, reader); @@ -325,7 +325,7 @@ public void OpenTypedReader_Should_Reject_A_Negative_Batch_Size() public void OpenTypedReader_Should_Reject_A_Null_Handle() { Assert.Equal(NativeStatus.InvalidHandle, - TypedApi.OpenTypedReader(null, Specs(), headerRow: 0, maxRows: 10, out nint reader)); + TypedApi.OpenTypedReader(null, 0, Specs(), headerRow: 0, maxRows: 10, out nint reader)); Assert.Equal(0, reader); } @@ -334,7 +334,7 @@ public void NextTypedBatch_Should_Report_InvalidHandle_For_A_Closed_Reader() { Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, Specs(), 0, 10, out nint reader)); + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, 0, Specs(), 0, 10, out nint reader)); TypedApi.CloseTypedReader(reader); @@ -347,7 +347,7 @@ public void CloseTypedReader_Should_Be_A_No_Op_On_Zero_And_On_A_Second_Close() { Assert.Equal(NativeStatus.Ok, NativeApiTests.OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); using NativeHandle live = handle!; - Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, Specs(), 0, 10, out nint reader)); + Assert.Equal(NativeStatus.Ok, TypedApi.OpenTypedReader(live, 0, Specs(), 0, 10, out nint reader)); TypedApi.CloseTypedReader(reader); TypedApi.CloseTypedReader(reader); From f3fee8597f4ca6f8a4ec352efe14af134411387c Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:00:38 -0300 Subject: [PATCH 23/49] feat(native)!: read rows through a cursor handle opened per sheet xl_rows_open returns a cursor with its own enumerator, so several cursors read one workbook at once and survive xl_close. The workbook handle no longer has a current sheet: xl_move_to_sheet and xl_sheet_name are removed, and xl_sheet_visibility_at and xl_sheet_index are added. The struct filled by the decoded read-all is renamed xl_rows_decoded. Co-Authored-By: Claude Sonnet 5.5 --- rust/excelreader/excelreader.def | 14 +- src/ExcelReader.Native/Exports.Reading.cs | 98 +++++--- src/ExcelReader.Native/Exports.cs | 6 + src/ExcelReader.Native/NativeHandle.cs | 51 ----- .../Reading/NativeRowCursor.cs | 37 +++ .../Reading/ReadApi.Rows.All.cs | 53 +++-- .../Reading/ReadApi.Rows.cs | 107 ++++++--- .../Reading/ReadApi.Sheets.cs | 45 ++-- .../include/excelreader.def | 14 +- src/ExcelReader.Native/include/excelreader.h | 44 +++- .../Native/NativeRowReadBenchmark.cs | 20 +- tests/ExcelReader.NativePgoTrainer/Program.cs | 8 +- .../Native/ExportsOutParamTests.cs | 29 ++- .../Native/NativeApiConcurrencyTests.cs | 3 +- .../Native/NativeApiTests.ParallelTyped.cs | 7 +- .../Native/NativeApiTests.Reading.cs | 215 +++++++----------- .../Native/NativeApiTests.RowView.cs | 36 +-- .../Native/NativeApiTests.Sheets.cs | 141 ++++++++++++ .../Native/NativeApiTests.Typed.cs | 5 +- .../Native/NativeApiTests.cs | 6 + .../Native/NativeWriterHandleTests.cs | 6 +- .../Reader/SheetVisibilityTests.cs | 2 +- 22 files changed, 592 insertions(+), 355 deletions(-) create mode 100644 src/ExcelReader.Native/Reading/NativeRowCursor.cs diff --git a/rust/excelreader/excelreader.def b/rust/excelreader/excelreader.def index 0e8f3cc6..37a46905 100644 --- a/rust/excelreader/excelreader.def +++ b/rust/excelreader/excelreader.def @@ -6,14 +6,16 @@ EXPORTS xl_open_memory_ex xl_close xl_sheet_count - xl_sheet_name xl_sheet_name_at - xl_move_to_sheet xl_is_date1904 - xl_next_row - xl_next_row_view - xl_read_all_decoded - xl_read_all_blob + xl_sheet_visibility_at + xl_sheet_index + xl_rows_open + xl_rows_close + xl_rows_next + xl_rows_next_view + xl_rows_read_all_decoded + xl_rows_read_all_blob xl_free_rows xl_parse_typed xl_free_table diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index 1b5df6d2..c02726ab 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -90,100 +90,140 @@ public static int SheetCount(nint handle, int* outCount) return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_sheet_name")] - public static int SheetName(nint handle, byte* buffer, int capacity, int* outLength) + [UnmanagedCallersOnly(EntryPoint = "xl_sheet_name_at")] + public static int SheetNameAt(nint handle, int index, byte* buffer, int capacity, int* outLength) { if (!IsValidOutBuffer(buffer, capacity, outLength)) { return NativeStatus.InvalidArgument; } - int status = ReadApi.SheetName(Resolve(handle), new Span(buffer, capacity), out int length); + int status = ReadApi.SheetNameAt(Resolve(handle), index, new Span(buffer, capacity), out int length); *outLength = length; return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_sheet_name_at")] - public static int SheetNameAt(nint handle, int index, byte* buffer, int capacity, int* outLength) + [UnmanagedCallersOnly(EntryPoint = "xl_is_date1904")] + public static int IsDate1904(nint handle, int* outFlag) { - if (!IsValidOutBuffer(buffer, capacity, outLength)) + if (outFlag is null) { return NativeStatus.InvalidArgument; } - int status = ReadApi.SheetNameAt(Resolve(handle), index, new Span(buffer, capacity), out int length); - *outLength = length; + int status = ReadApi.IsDate1904(Resolve(handle), out int flag); + *outFlag = flag; return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_move_to_sheet")] - public static int MoveToSheet(nint handle, int index) + [UnmanagedCallersOnly(EntryPoint = "xl_sheet_visibility_at")] + public static int SheetVisibilityAt(nint handle, int index, int* outVisibility) { - return ReadApi.MoveToSheet(Resolve(handle), index); + if (outVisibility is null) + { + return NativeStatus.InvalidArgument; + } + + int status = ReadApi.SheetVisibilityAt(Resolve(handle), index, out int visibility); + *outVisibility = visibility; + return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_is_date1904")] - public static int IsDate1904(nint handle, int* outFlag) + [UnmanagedCallersOnly(EntryPoint = "xl_sheet_index")] + public static int SheetIndex(nint handle, byte* name, int nameLength, int* outIndex) { - if (outFlag is null) + if (outIndex is null) + { + return NativeStatus.InvalidArgument; + } + *outIndex = -1; + if (nameLength < 0 || (name is null && nameLength != 0)) { return NativeStatus.InvalidArgument; } - int status = ReadApi.IsDate1904(Resolve(handle), out int flag); - *outFlag = flag; + int status = ReadApi.SheetIndex(Resolve(handle), new ReadOnlySpan(name, nameLength), out int index); + *outIndex = index; + return status; + } + + [UnmanagedCallersOnly(EntryPoint = "xl_rows_open")] + public static int RowsOpen(nint handle, int sheet, nint* outRows) + { + if (outRows is null) + { + return NativeStatus.InvalidArgument; + } + *outRows = 0; + + int status = ReadApi.OpenRows(Resolve(handle), sheet, out NativeRowCursor? cursor); + if (cursor is not null) + { + *outRows = NativeHandleTable.Register(cursor); + } return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_next_row")] - public static int NextRow(nint handle, byte* buffer, int capacity, int* outWritten) + [UnmanagedCallersOnly(EntryPoint = "xl_rows_close")] + public static int RowsClose(nint rows) + { + if (!NativeHandleTable.TryUnregister(rows, out NativeRowCursor? cursor)) + { + return NativeStatus.InvalidHandle; + } + + return ReadApi.CloseRows(cursor); + } + + [UnmanagedCallersOnly(EntryPoint = "xl_rows_next")] + public static int RowsNext(nint rows, byte* buffer, int capacity, int* outWritten) { if (!IsValidOutBuffer(buffer, capacity, outWritten)) { return NativeStatus.InvalidArgument; } - int status = ReadApi.NextRow(Resolve(handle), new Span(buffer, capacity), out int written); + int status = ReadApi.NextRow(ResolveRows(rows), new Span(buffer, capacity), out int written); *outWritten = written; return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_next_row_view")] - public static int NextRowView(nint handle, NativeRow* outRow) + [UnmanagedCallersOnly(EntryPoint = "xl_rows_next_view")] + public static int RowsNextView(nint rows, NativeRow* outRow) { if (outRow is null) { return NativeStatus.InvalidArgument; } - int status = ReadApi.NextRowView(Resolve(handle), out NativeRow row); + int status = ReadApi.NextRowView(ResolveRows(rows), out NativeRow row); *outRow = row; return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_read_all_blob")] - public static int ReadAllBlob(nint handle, byte* buffer, int capacity, int* outWritten) + [UnmanagedCallersOnly(EntryPoint = "xl_rows_read_all_blob")] + public static int RowsReadAllBlob(nint rows, byte* buffer, int capacity, int* outWritten) { if (!IsValidOutBuffer(buffer, capacity, outWritten)) { return NativeStatus.InvalidArgument; } - int status = ReadApi.ReadAllBlob(Resolve(handle), new Span(buffer, capacity), out int written); + int status = ReadApi.ReadAllBlob(ResolveRows(rows), new Span(buffer, capacity), out int written); *outWritten = written; return status; } - [UnmanagedCallersOnly(EntryPoint = "xl_read_all_decoded")] - public static int ReadAllDecoded(nint handle, NativeRows* outRows) + [UnmanagedCallersOnly(EntryPoint = "xl_rows_read_all_decoded")] + public static int RowsReadAllDecoded(nint rows, NativeRows* outRows) { if (outRows is null) { return NativeStatus.InvalidArgument; } - int status = ReadApi.ReadAllDecoded(Resolve(handle), out NativeRows rows); - *outRows = rows; + int status = ReadApi.ReadAllDecoded(ResolveRows(rows), out NativeRows decoded); + *outRows = decoded; return status; } diff --git a/src/ExcelReader.Native/Exports.cs b/src/ExcelReader.Native/Exports.cs index 0d5e4b30..2e90661f 100644 --- a/src/ExcelReader.Native/Exports.cs +++ b/src/ExcelReader.Native/Exports.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Text; +using ExcelReader.Native.Reading; using ExcelReader.Native.Typed; namespace ExcelReader.Native @@ -156,6 +157,11 @@ public static int AbiVersion() return NativeHandleTable.Resolve(handle); } + internal static NativeRowCursor? ResolveRows(nint rows) + { + return NativeHandleTable.Resolve(rows); + } + internal static bool TryFree(nint handle, out NativeHandle? target) { return NativeHandleTable.TryUnregister(handle, out target); diff --git a/src/ExcelReader.Native/NativeHandle.cs b/src/ExcelReader.Native/NativeHandle.cs index 99348d60..e05502bf 100644 --- a/src/ExcelReader.Native/NativeHandle.cs +++ b/src/ExcelReader.Native/NativeHandle.cs @@ -1,5 +1,4 @@ using ExcelReader.Core.Reader; -using ExcelReader.Native.Reading; namespace ExcelReader.Native { @@ -8,62 +7,12 @@ internal sealed class NativeHandle : IDisposable internal NativeHandle(IExcelWorkbook workbook) { Workbook = workbook; - Scratch = new byte[4096]; } internal IExcelWorkbook Workbook { get; } - internal int CurrentSheet { get; private set; } - - internal IExcelSheet Sheet - { - get - { - return Workbook.SheetAt(CurrentSheet); - } - } - - internal void MoveToSheet(int index) - { - _ = Workbook.SheetAt(index); - CurrentSheet = index; - ResetRows(); - } - - internal IExcelRowEnumerator? Rows { get; set; } - - internal byte[] Scratch { get; set; } - - internal int PendingLength { get; set; } - - internal bool HasPending { get; set; } - - internal RowViewBuffer? View { get; set; } - - internal ChunkedBuffer? AllRowsScratch { get; set; } - - internal int AllRowsCount { get; set; } - - internal int AllRowsLength { get; set; } - - internal bool AllRowsPending { get; set; } - - internal void ResetRows() - { - Rows?.Dispose(); - Rows = null; - HasPending = false; - PendingLength = 0; - AllRowsPending = false; - AllRowsLength = 0; - AllRowsCount = 0; - AllRowsScratch = null; - } - public void Dispose() { - ResetRows(); - View?.Dispose(); Workbook.Dispose(); } } diff --git a/src/ExcelReader.Native/Reading/NativeRowCursor.cs b/src/ExcelReader.Native/Reading/NativeRowCursor.cs new file mode 100644 index 00000000..3dbfb01e --- /dev/null +++ b/src/ExcelReader.Native/Reading/NativeRowCursor.cs @@ -0,0 +1,37 @@ +using ExcelReader.Core.Reader; + +namespace ExcelReader.Native.Reading +{ + internal sealed class NativeRowCursor : IDisposable + { + internal NativeRowCursor(IExcelRowEnumerator rows) + { + Rows = rows; + Scratch = new byte[4096]; + } + + internal IExcelRowEnumerator Rows { get; } + + internal byte[] Scratch { get; set; } + + internal int PendingLength { get; set; } + + internal bool HasPending { get; set; } + + internal RowViewBuffer? View { get; set; } + + internal ChunkedBuffer? AllRowsScratch { get; set; } + + internal int AllRowsCount { get; set; } + + internal int AllRowsLength { get; set; } + + internal bool AllRowsPending { get; set; } + + public void Dispose() + { + Rows.Dispose(); + View?.Dispose(); + } + } +} diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs index 31ab58e0..3ee39a8b 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs @@ -6,10 +6,10 @@ namespace ExcelReader.Native.Reading { internal static unsafe partial class ReadApi { - internal static int ReadAllBlob(NativeHandle? handle, Span buffer, out int written) + internal static int ReadAllBlob(NativeRowCursor? cursor, Span buffer, out int written) { written = 0; - if (handle is null) + if (cursor is null) { return NativeStatus.InvalidHandle; } @@ -17,59 +17,58 @@ internal static int ReadAllBlob(NativeHandle? handle, Span buffer, out int NativeApi.ClearLastError(); try { - if (!handle.AllRowsPending) + if (!cursor.AllRowsPending) { - AccumulateAllRows(handle); + AccumulateAllRows(cursor); } - written = handle.AllRowsLength; - if (buffer.Length < handle.AllRowsLength) + written = cursor.AllRowsLength; + if (buffer.Length < cursor.AllRowsLength) { return NativeStatus.BufferTooSmall; } - BinaryPrimitives.WriteInt32LittleEndian(buffer, handle.AllRowsCount); - handle.AllRowsScratch?.CopyTo(buffer[sizeof(int)..handle.AllRowsLength]); - handle.AllRowsPending = false; + BinaryPrimitives.WriteInt32LittleEndian(buffer, cursor.AllRowsCount); + cursor.AllRowsScratch?.CopyTo(buffer[sizeof(int)..cursor.AllRowsLength]); + cursor.AllRowsPending = false; return NativeStatus.Ok; } catch (Exception exception) { NativeApi.SetLastError(exception.Message); - handle.AllRowsPending = false; - handle.AllRowsLength = 0; - handle.AllRowsCount = 0; - handle.AllRowsScratch = null; + cursor.AllRowsPending = false; + cursor.AllRowsLength = 0; + cursor.AllRowsCount = 0; + cursor.AllRowsScratch = null; return NativeStatus.Error; } } - private static void AccumulateAllRows(NativeHandle handle) + private static void AccumulateAllRows(NativeRowCursor cursor) { ChunkedBuffer output = new(); int rowCount = 0; - if (handle.HasPending) + if (cursor.HasPending) { - AppendRow(output, handle.Scratch.AsSpan(0, handle.PendingLength)); - handle.HasPending = false; + AppendRow(output, cursor.Scratch.AsSpan(0, cursor.PendingLength)); + cursor.HasPending = false; rowCount++; } byte[] rowScratch = []; - handle.Rows ??= handle.Sheet.GetEnumerator(); - while (handle.Rows.MoveNext()) + while (cursor.Rows.MoveNext()) { - Row row = handle.Rows.Current; + Row row = cursor.Rows.Current; int rowLength = RowBlob.Serialize(row, ref rowScratch); AppendRow(output, rowScratch.AsSpan(0, rowLength)); rowCount++; } - handle.AllRowsScratch = output; - handle.AllRowsCount = rowCount; - handle.AllRowsLength = sizeof(int) + output.Count; - handle.AllRowsPending = true; + cursor.AllRowsScratch = output; + cursor.AllRowsCount = rowCount; + cursor.AllRowsLength = sizeof(int) + output.Count; + cursor.AllRowsPending = true; } private static void AppendRow(ChunkedBuffer output, ReadOnlySpan rowBlob) @@ -88,10 +87,10 @@ private static void AppendRow(ChunkedBuffer output, ReadOnlySpan row output.AddRange(rowBlob); } - internal static int ReadAllDecoded(NativeHandle? handle, out NativeRows rows) + internal static int ReadAllDecoded(NativeRowCursor? cursor, out NativeRows rows) { rows = default; - if (handle is null) + if (cursor is null) { return NativeStatus.InvalidHandle; } @@ -101,7 +100,7 @@ internal static int ReadAllDecoded(NativeHandle? handle, out NativeRows rows) { while (true) { - int status = NextRowDecoded(handle, out NativeRow row); + int status = NextRowDecoded(cursor, out NativeRow row); if (status == NativeStatus.Eof) { break; diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs index 59462da6..b1f0eec0 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.cs @@ -6,10 +6,57 @@ namespace ExcelReader.Native.Reading { internal static unsafe partial class ReadApi { - internal static int NextRowDecoded(NativeHandle? handle, out NativeRow row) + internal static int OpenRows(NativeHandle? handle, int sheet, out NativeRowCursor? cursor) + { + cursor = null; + if (handle is null) + { + return NativeStatus.InvalidHandle; + } + + NativeApi.ClearLastError(); + int status = ResolveSheet(handle, sheet, out IExcelSheet? resolved); + if (status != NativeStatus.Ok) + { + return status; + } + + try + { + cursor = new NativeRowCursor(resolved!.GetEnumerator()); + return NativeStatus.Ok; + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } + } + + internal static int CloseRows(NativeRowCursor? cursor) + { + if (cursor is null) + { + return NativeStatus.InvalidHandle; + } + + NativeApi.ClearLastError(); + try + { + cursor.Dispose(); + return NativeStatus.Ok; + } + catch (Exception exception) + { + NativeApi.SetLastError(exception.Message); + return NativeStatus.Error; + } + } + + internal static int NextRowDecoded(NativeRowCursor? cursor, out NativeRow row) { row = default; - int status = NextRow(handle, Span.Empty, out _); + int status = NextRow(cursor, Span.Empty, out _); if (status != NativeStatus.BufferTooSmall) { return status; @@ -17,7 +64,7 @@ internal static int NextRowDecoded(NativeHandle? handle, out NativeRow row) try { - return DecodePendingRow(handle!, out row); + return DecodePendingRow(cursor!, out row); } catch (Exception exception) { @@ -36,10 +83,10 @@ internal static void FreeRow(ref NativeRow row) row = default; } - internal static int NextRow(NativeHandle? handle, Span buffer, out int written) + internal static int NextRow(NativeRowCursor? cursor, Span buffer, out int written) { written = 0; - if (handle is null) + if (cursor is null) { return NativeStatus.InvalidHandle; } @@ -47,29 +94,28 @@ internal static int NextRow(NativeHandle? handle, Span buffer, out int wri NativeApi.ClearLastError(); try { - if (!handle.HasPending) + if (!cursor.HasPending) { - handle.Rows ??= handle.Sheet.GetEnumerator(); - if (!handle.Rows.MoveNext()) + if (!cursor.Rows.MoveNext()) { return NativeStatus.Eof; } - Row row = handle.Rows.Current; - byte[] scratch = handle.Scratch; - handle.PendingLength = RowBlob.Serialize(row, ref scratch); - handle.Scratch = scratch; - handle.HasPending = true; + Row row = cursor.Rows.Current; + byte[] scratch = cursor.Scratch; + cursor.PendingLength = RowBlob.Serialize(row, ref scratch); + cursor.Scratch = scratch; + cursor.HasPending = true; } - written = handle.PendingLength; - if (buffer.Length < handle.PendingLength) + written = cursor.PendingLength; + if (buffer.Length < cursor.PendingLength) { return NativeStatus.BufferTooSmall; } - handle.Scratch.AsSpan(0, handle.PendingLength).CopyTo(buffer); - handle.HasPending = false; + cursor.Scratch.AsSpan(0, cursor.PendingLength).CopyTo(buffer); + cursor.HasPending = false; return NativeStatus.Ok; } catch (Exception exception) @@ -79,10 +125,10 @@ internal static int NextRow(NativeHandle? handle, Span buffer, out int wri } } - internal static int NextRowView(NativeHandle? handle, out NativeRow row) + internal static int NextRowView(NativeRowCursor? cursor, out NativeRow row) { row = default; - if (handle is null) + if (cursor is null) { return NativeStatus.InvalidHandle; } @@ -90,20 +136,19 @@ internal static int NextRowView(NativeHandle? handle, out NativeRow row) NativeApi.ClearLastError(); try { - handle.View ??= new RowViewBuffer(); - if (handle.HasPending) + cursor.View ??= new RowViewBuffer(); + if (cursor.HasPending) { - handle.HasPending = false; - row = handle.View.Fill(handle.Scratch.AsSpan(0, handle.PendingLength)); + cursor.HasPending = false; + row = cursor.View.Fill(cursor.Scratch.AsSpan(0, cursor.PendingLength)); return NativeStatus.Ok; } - handle.Rows ??= handle.Sheet.GetEnumerator(); - if (!handle.Rows.MoveNext()) + if (!cursor.Rows.MoveNext()) { return NativeStatus.Eof; } - row = handle.View.Fill(handle.Rows.Current); + row = cursor.View.Fill(cursor.Rows.Current); return NativeStatus.Ok; } catch (Exception exception) @@ -114,19 +159,19 @@ internal static int NextRowView(NativeHandle? handle, out NativeRow row) } } - private static int DecodePendingRow(NativeHandle handle, out NativeRow row) + private static int DecodePendingRow(NativeRowCursor cursor, out NativeRow row) { row = default; - ReadOnlySpan blob = handle.Scratch.AsSpan(0, handle.PendingLength); + ReadOnlySpan blob = cursor.Scratch.AsSpan(0, cursor.PendingLength); int cellCount = BinaryPrimitives.ReadInt32LittleEndian(blob); if (cellCount == 0) { - handle.HasPending = false; + cursor.HasPending = false; return NativeStatus.Ok; } int cellSize = sizeof(NativeRowCell); - int valueBytes = handle.PendingLength - sizeof(int) - checked(cellCount * RowBlob.CellHeaderSize); + int valueBytes = cursor.PendingLength - sizeof(int) - checked(cellCount * RowBlob.CellHeaderSize); IntPtr block = Marshal.AllocHGlobal(checked((cellCount * cellSize) + valueBytes + cellCount)); NativeRowCell* cells = (NativeRowCell*)block; @@ -153,7 +198,7 @@ private static int DecodePendingRow(NativeHandle handle, out NativeRow row) valueOffset += length + 1; } - handle.HasPending = false; + cursor.HasPending = false; row = new NativeRow { CellCount = cellCount, Cells = block }; return NativeStatus.Ok; } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs index 1e4d94d2..9b7f6c72 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs @@ -47,26 +47,6 @@ internal static int SheetCount(NativeHandle? handle, out int count) } } - internal static int SheetName(NativeHandle? handle, Span buffer, out int length) - { - length = 0; - if (handle is null) - { - return NativeStatus.InvalidHandle; - } - - NativeApi.ClearLastError(); - try - { - return CopyUtf8(handle.Sheet.Name, buffer, out length); - } - catch (Exception exception) - { - NativeApi.SetLastError(exception.Message); - return NativeStatus.Error; - } - } - internal static int SheetNameAt(NativeHandle? handle, int index, Span buffer, out int length) { length = 0; @@ -104,8 +84,26 @@ private static int CopyUtf8(string value, Span buffer, out int length) return NativeStatus.Ok; } - internal static int MoveToSheet(NativeHandle? handle, int index) + internal static int SheetVisibilityAt(NativeHandle? handle, int index, out int visibility) + { + visibility = 0; + if (handle is null) + { + return NativeStatus.InvalidHandle; + } + + NativeApi.ClearLastError(); + int status = ResolveSheet(handle, index, out IExcelSheet? sheet); + if (status == NativeStatus.Ok) + { + visibility = (int)sheet!.Visibility; + } + return status; + } + + internal static int SheetIndex(NativeHandle? handle, ReadOnlySpan utf8Name, out int index) { + index = -1; if (handle is null) { return NativeStatus.InvalidHandle; @@ -114,7 +112,10 @@ internal static int MoveToSheet(NativeHandle? handle, int index) NativeApi.ClearLastError(); try { - handle.MoveToSheet(index); + if (handle.Workbook.TryGetSheet(Encoding.UTF8.GetString(utf8Name), out IExcelSheet? sheet)) + { + index = sheet!.Index; + } return NativeStatus.Ok; } catch (Exception exception) diff --git a/src/ExcelReader.Native/include/excelreader.def b/src/ExcelReader.Native/include/excelreader.def index 0e8f3cc6..37a46905 100644 --- a/src/ExcelReader.Native/include/excelreader.def +++ b/src/ExcelReader.Native/include/excelreader.def @@ -6,14 +6,16 @@ EXPORTS xl_open_memory_ex xl_close xl_sheet_count - xl_sheet_name xl_sheet_name_at - xl_move_to_sheet xl_is_date1904 - xl_next_row - xl_next_row_view - xl_read_all_decoded - xl_read_all_blob + xl_sheet_visibility_at + xl_sheet_index + xl_rows_open + xl_rows_close + xl_rows_next + xl_rows_next_view + xl_rows_read_all_decoded + xl_rows_read_all_blob xl_free_rows xl_parse_typed xl_free_table diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 4136ed48..c9cfd06e 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -38,6 +38,11 @@ extern "C" { typedef struct xl_workbook xl_workbook; typedef struct xl_typed_reader xl_typed_reader; +typedef struct xl_rows xl_rows; + +#define XL_SHEET_VISIBLE 0 +#define XL_SHEET_HIDDEN 1 +#define XL_SHEET_VERY_HIDDEN 2 typedef struct xl_row_cell { int32_t column; @@ -88,32 +93,47 @@ int32_t xl_open_memory_ex(const uint8_t* data, int32_t data_len, int32_t format, int32_t xl_close(xl_workbook* handle); +/* A workbook handle may be used from several threads at once. index and sheet are zero-based: + negative is XL_INVALID_ARGUMENT, at or past xl_sheet_count is XL_ERROR. */ int32_t xl_sheet_count(xl_workbook* handle, int32_t* out_count); -int32_t xl_sheet_name(xl_workbook* handle, uint8_t* buffer, int32_t capacity, int32_t* out_len); - int32_t xl_sheet_name_at(xl_workbook* handle, int32_t index, uint8_t* buffer, int32_t capacity, int32_t* out_len); -int32_t xl_move_to_sheet(xl_workbook* handle, int32_t index); +/* Writes XL_SHEET_VISIBLE, XL_SHEET_HIDDEN or XL_SHEET_VERY_HIDDEN. */ +int32_t xl_sheet_visibility_at(xl_workbook* handle, int32_t index, int32_t* out_visibility); + +/* Finds a sheet by name, ignoring case. A name that matches no sheet is not an error: the function + returns XL_OK and writes -1. */ +int32_t xl_sheet_index(xl_workbook* handle, const uint8_t* name, int32_t name_len, int32_t* out_index); int32_t xl_is_date1904(xl_workbook* handle, int32_t* out_flag); -int32_t xl_next_row(xl_workbook* handle, uint8_t* buffer, int32_t capacity, int32_t* out_written); +/* Opens a cursor over one sheet. A cursor reads its sheet once, front to back; open another to read + it again. Any number of cursors may be open on one workbook, on the same sheet or on different + ones, and each may run on its own thread. Use one cursor from one thread at a time. A cursor keeps + working after xl_close on its workbook. Release it with xl_rows_close. */ +int32_t xl_rows_open(xl_workbook* handle, int32_t sheet, xl_rows** out_rows); + +int32_t xl_rows_close(xl_rows* rows); + +int32_t xl_rows_next(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); -/* out_row points into memory the handle owns: overwritten by the next xl_next_row_view, released by - xl_close. Values are NUL-terminated. Do not pass it to xl_free_rows. */ -int32_t xl_next_row_view(xl_workbook* handle, xl_row* out_row); +/* out_row points into memory the cursor owns: overwritten by the next xl_rows_next_view on that + cursor, released by xl_rows_close. Values are NUL-terminated. Do not pass it to xl_free_rows. */ +int32_t xl_rows_next_view(xl_rows* rows, xl_row* out_row); -int32_t xl_read_all_blob(xl_workbook* handle, uint8_t* buffer, int32_t capacity, int32_t* out_written); +/* Reads the rows the cursor has not yet returned. */ +int32_t xl_rows_read_all_blob(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); -typedef struct xl_rows { +typedef struct xl_rows_decoded { int32_t row_count; xl_row* rows; -} xl_rows; +} xl_rows_decoded; -int32_t xl_read_all_decoded(xl_workbook* handle, xl_rows* out_rows); +/* Reads the rows the cursor has not yet returned. Release out_rows with xl_free_rows. */ +int32_t xl_rows_read_all_decoded(xl_rows* rows, xl_rows_decoded* out_rows); -void xl_free_rows(xl_rows* rows); +void xl_free_rows(xl_rows_decoded* rows); #define XL_T_STRING 0 #define XL_T_I64 1 diff --git a/tests/ExcelReader.Benchmarks/Native/NativeRowReadBenchmark.cs b/tests/ExcelReader.Benchmarks/Native/NativeRowReadBenchmark.cs index 9306cb4c..f0806f86 100644 --- a/tests/ExcelReader.Benchmarks/Native/NativeRowReadBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/Native/NativeRowReadBenchmark.cs @@ -23,14 +23,16 @@ public void Setup() public int NextRowBlob() { using NativeHandle handle = Open(); + ReadApi.OpenRows(handle, 0, out NativeRowCursor? opened); + using NativeRowCursor cursor = opened!; int rows = 0; while (true) { - int status = ReadApi.NextRow(handle, _buffer, out int written); + int status = ReadApi.NextRow(cursor, _buffer, out int written); if (status == NativeStatus.BufferTooSmall) { _buffer = new byte[written]; - status = ReadApi.NextRow(handle, _buffer, out written); + status = ReadApi.NextRow(cursor, _buffer, out written); } if (status == NativeStatus.Eof) { @@ -46,10 +48,12 @@ public int NextRowBlob() public int NextRowDecoded() { using NativeHandle handle = Open(); + ReadApi.OpenRows(handle, 0, out NativeRowCursor? opened); + using NativeRowCursor cursor = opened!; int rows = 0; while (true) { - int status = ReadApi.NextRowDecoded(handle, out NativeRow row); + int status = ReadApi.NextRowDecoded(cursor, out NativeRow row); if (status == NativeStatus.Eof) { break; @@ -65,7 +69,9 @@ public int NextRowDecoded() public int ReadAllBlob() { using NativeHandle handle = Open(); - int status = ReadApi.ReadAllBlob(handle, Span.Empty, out int written); + ReadApi.OpenRows(handle, 0, out NativeRowCursor? opened); + using NativeRowCursor cursor = opened!; + int status = ReadApi.ReadAllBlob(cursor, Span.Empty, out int written); if (status != NativeStatus.BufferTooSmall) { throw new InvalidOperationException($"expected XL_BUFFER_TOO_SMALL sizing probe, got status {status}."); @@ -74,7 +80,7 @@ public int ReadAllBlob() { _buffer = new byte[written]; } - Verify(ReadApi.ReadAllBlob(handle, _buffer.AsSpan(0, written), out _)); + Verify(ReadApi.ReadAllBlob(cursor, _buffer.AsSpan(0, written), out _)); return VerifyRows(RowCountOf(_buffer)); } @@ -82,7 +88,9 @@ public int ReadAllBlob() public int ReadAllDecoded() { using NativeHandle handle = Open(); - int status = ReadApi.ReadAllDecoded(handle, out NativeRows rows); + ReadApi.OpenRows(handle, 0, out NativeRowCursor? opened); + using NativeRowCursor cursor = opened!; + int status = ReadApi.ReadAllDecoded(cursor, out NativeRows rows); try { Verify(status); diff --git a/tests/ExcelReader.NativePgoTrainer/Program.cs b/tests/ExcelReader.NativePgoTrainer/Program.cs index 046166da..cf7be64b 100644 --- a/tests/ExcelReader.NativePgoTrainer/Program.cs +++ b/tests/ExcelReader.NativePgoTrainer/Program.cs @@ -55,14 +55,18 @@ private static int Main() } if (status == NativeStatus.Ok && (status = ReadApi.OpenMemory(bytes, format, out handle)) == NativeStatus.Ok) { - while ((status = ReadApi.NextRow(handle, rowBuffer, out _)) == NativeStatus.Ok) + ReadApi.OpenRows(handle, 0, out NativeRowCursor? rows); + using NativeRowCursor cursor = rows!; + while ((status = ReadApi.NextRow(cursor, rowBuffer, out _)) == NativeStatus.Ok) { } ReadApi.Close(handle); } if (status == NativeStatus.Eof && (status = ReadApi.OpenMemory(bytes, format, out handle)) == NativeStatus.Ok) { - while ((status = ReadApi.NextRowView(handle, out _)) == NativeStatus.Ok) + ReadApi.OpenRows(handle, 0, out NativeRowCursor? viewRows); + using NativeRowCursor viewCursor = viewRows!; + while ((status = ReadApi.NextRowView(viewCursor, out _)) == NativeStatus.Ok) { } ReadApi.Close(handle); diff --git a/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs b/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs index 78c01dac..c0ff90de 100644 --- a/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs +++ b/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs @@ -7,15 +7,14 @@ public sealed unsafe class ExportsOutParamTests { private const int Sentinel = 0x5A5A5A5A; - public static TheoryData BufferExports => ["xl_sheet_name", "xl_next_row", "xl_read_all_blob", "xl_last_error"]; + public static TheoryData BufferExports => ["xl_rows_next", "xl_rows_read_all_blob", "xl_last_error"]; private static int CallBufferExport(string export, int capacity, int* outValue) { return export switch { - "xl_sheet_name" => ((delegate* unmanaged)&Exports.SheetName)(0, null, capacity, outValue), - "xl_next_row" => ((delegate* unmanaged)&Exports.NextRow)(0, null, capacity, outValue), - "xl_read_all_blob" => ((delegate* unmanaged)&Exports.ReadAllBlob)(0, null, capacity, outValue), + "xl_rows_next" => ((delegate* unmanaged)&Exports.RowsNext)(0, null, capacity, outValue), + "xl_rows_read_all_blob" => ((delegate* unmanaged)&Exports.RowsReadAllBlob)(0, null, capacity, outValue), "xl_last_error" => ((delegate* unmanaged)&Exports.LastError)(null, capacity, outValue), _ => throw new ArgumentOutOfRangeException(nameof(export)), }; @@ -67,5 +66,27 @@ public void CsvAggregateFileRejectingItsArgumentsStillNullsTheStateOutParam() Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Equal(nint.Zero, (nint)outState); } + + [Fact] + public void SheetIndexRejectingItsArgumentsStillWritesMinusOne() + { + int outValue = Sentinel; + + int status = ((delegate* unmanaged)&Exports.SheetIndex)(0, null, 5, &outValue); + + Assert.Equal(NativeStatus.InvalidArgument, status); + Assert.Equal(-1, outValue); + } + + [Fact] + public void RowsOpenOnAnUnknownWorkbookStillZeroesTheCursorOutParam() + { + nint outRows = Sentinel; + + int status = ((delegate* unmanaged)&Exports.RowsOpen)(0, 0, &outRows); + + Assert.Equal(NativeStatus.InvalidHandle, status); + Assert.Equal(0, outRows); + } } } diff --git a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs index 0756429f..1b443ce2 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs @@ -16,9 +16,10 @@ public Task IndependentHandlesOnSeparateThreadsDoNotInterfere() { Assert.Equal(NativeStatus.Ok, ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, out NativeHandle? handle)); Assert.NotNull(handle); + using NativeRowCursor cursor = NativeApiTests.OpenRows(handle); int rowCount = 0; - while (ReadApi.NextRow(handle, new byte[64 * 1024], out _) != NativeStatus.Eof) + while (ReadApi.NextRow(cursor, new byte[64 * 1024], out _) != NativeStatus.Eof) { rowCount++; } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs index 9cdc7a9c..e124be77 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.ParallelTyped.cs @@ -325,10 +325,11 @@ public void ParseTypedTable_Should_Read_From_A_File_Without_Moving_The_Row_Curso try { byte[] blob = new byte[1 << 16]; + using NativeRowCursor cursor = OpenRows(handle); const int advanced = 5_000; for (int i = 0; i < advanced; i++) { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, blob, out _)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, blob, out _)); } Assert.Equal(NativeStatus.Ok, TypedApi.ParseTypedTable(handle, 0, MixedSpecs, headerRow: 1, 4, out NativeTable actual, SmallChunk)); @@ -342,11 +343,11 @@ public void ParseTypedTable_Should_Read_From_A_File_Without_Moving_The_Row_Curso TypedApi.FreeTable(ref actual); } - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, blob, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, blob, out int written)); Assert.Equal("item4999", DecodeRow(blob.AsSpan(0, written))[0].Value); int remaining = 1; string last = ""; - while (ReadApi.NextRow(handle, blob, out written) == NativeStatus.Ok) + while (ReadApi.NextRow(cursor, blob, out written) == NativeStatus.Ok) { remaining++; last = DecodeRow(blob.AsSpan(0, written))[0].Value; diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index 66402c9f..2991a989 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -292,8 +292,9 @@ public void OpenFileEx_Applies_An_Explicit_Csv_Delimiter() Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); List row = DecodeRow(buffer.AsSpan(0, written)); Assert.Equal(2, row.Count); Assert.Equal("name", row[0].Value); @@ -321,8 +322,9 @@ public void OpenFileEx_Sniffs_The_Csv_Dialect_When_Requested() Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); List row = DecodeRow(buffer.AsSpan(0, written)); Assert.Equal(2, row.Count); Assert.Equal("name", row[0].Value); @@ -361,8 +363,9 @@ public void OpenFileEx_Applies_A_Tiny_Csv_Max_Cell_Bytes() Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[128 * 1024], out _)); - Assert.Equal(NativeStatus.Error, ReadApi.NextRow(handle, new byte[128 * 1024], out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[128 * 1024], out _)); + Assert.Equal(NativeStatus.Error, ReadApi.NextRow(cursor, new byte[128 * 1024], out _)); } finally { @@ -480,13 +483,13 @@ public void SheetCount_Should_Report_At_Least_One_Sheet() } [Fact] - public void SheetName_Should_Return_A_Non_Empty_Utf8_Name() + public void SheetNameAt_Should_Return_A_Non_Empty_Utf8_Name() { Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); try { Span buffer = stackalloc byte[256]; - int status = ReadApi.SheetName(handle, buffer, out int length); + int status = ReadApi.SheetNameAt(handle, 0, buffer, out int length); Assert.Equal(NativeStatus.Ok, status); Assert.True(length > 0); @@ -499,52 +502,7 @@ public void SheetName_Should_Return_A_Non_Empty_Utf8_Name() } [Fact] - public void SheetName_Should_Report_Required_Size_When_Buffer_Too_Small() - { - Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - try - { - int status = ReadApi.SheetName(handle, Span.Empty, out int length); - - Assert.Equal(NativeStatus.BufferTooSmall, status); - Assert.True(length > 0); - } - finally - { - ReadApi.Close(handle); - } - } - - [Fact] - public void MoveToSheet_Should_Accept_The_First_Sheet() - { - Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - try - { - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 0)); - } - finally - { - ReadApi.Close(handle); - } - } - - [Fact] - public void MoveToSheet_Should_Fail_For_An_Out_Of_Range_Index() - { - Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); - try - { - Assert.Equal(NativeStatus.Error, ReadApi.MoveToSheet(handle, 9999)); - } - finally - { - ReadApi.Close(handle); - } - } - - [Fact] - public void SheetNameAt_Should_Return_Each_Name_Without_Moving_The_Cursor() + public void SheetNameAt_Should_Return_Each_Name_Without_Disturbing_A_Cursor() { using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( [ @@ -554,7 +512,8 @@ public void SheetNameAt_Should_Return_Each_Name_Without_Moving_The_Cursor() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); Span buffer = stackalloc byte[64]; Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 0, buffer, out int firstLength)); @@ -562,10 +521,8 @@ public void SheetNameAt_Should_Return_Each_Name_Without_Moving_The_Cursor() Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 1, buffer, out int secondLength)); Assert.Equal("Second", Encoding.UTF8.GetString(buffer[..secondLength])); - Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, buffer, out int currentLength)); - Assert.Equal("First", Encoding.UTF8.GetString(buffer[..currentLength])); byte[] rowBuffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, rowBuffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, rowBuffer, out int written)); Assert.Equal("2", DecodeRow(rowBuffer.AsSpan(0, written))[0].Value); } finally @@ -650,7 +607,6 @@ public void IsDate1904_Should_Report_Zero_For_A_1900_Based_Workbook() public void Sheet_Functions_Should_Reject_A_Null_Handle() { Assert.Equal(NativeStatus.InvalidHandle, ReadApi.SheetCount(null, out _)); - Assert.Equal(NativeStatus.InvalidHandle, ReadApi.MoveToSheet(null, 0)); Assert.Equal(NativeStatus.InvalidHandle, ReadApi.IsDate1904(null, out _)); } @@ -662,9 +618,10 @@ public void NextRow_Should_Decode_A_Csv_Row_Exactly() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int first)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int first)); List header = DecodeRow(buffer.AsSpan(0, first)); Assert.Equal(2, header.Count); Assert.Equal(0, header[0].Column); @@ -672,12 +629,12 @@ public void NextRow_Should_Decode_A_Csv_Row_Exactly() Assert.Equal(1, header[1].Column); Assert.Equal("qty", header[1].Value); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int second)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int second)); List data = DecodeRow(buffer.AsSpan(0, second)); Assert.Equal("widget", data[0].Value); Assert.Equal("7", data[1].Value); - Assert.Equal(NativeStatus.Eof, ReadApi.NextRow(handle, buffer, out _)); + Assert.Equal(NativeStatus.Eof, ReadApi.NextRow(cursor, buffer, out _)); } finally { @@ -694,7 +651,8 @@ public void NextRowDecoded_Should_Expose_Values_Through_A_C_Struct() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(handle, out NativeRow row)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(cursor, out NativeRow row)); try { Assert.Equal(2, row.CellCount); @@ -716,8 +674,8 @@ public void NextRowDecoded_Should_Expose_Values_Through_A_C_Struct() ReadApi.FreeRow(ref row); } - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); - Assert.Equal(NativeStatus.Eof, ReadApi.NextRow(handle, new byte[4096], out _)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); + Assert.Equal(NativeStatus.Eof, ReadApi.NextRow(cursor, new byte[4096], out _)); } finally { @@ -734,9 +692,10 @@ public void NextRowDecoded_Should_Resume_A_Row_Pending_From_The_Blob_API() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(handle, Span.Empty, out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(cursor, Span.Empty, out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(handle, out NativeRow row)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(cursor, out NativeRow row)); try { NativeRowCell cell = Marshal.PtrToStructure(row.Cells); @@ -747,7 +706,7 @@ public void NextRowDecoded_Should_Resume_A_Row_Pending_From_The_Blob_API() ReadApi.FreeRow(ref row); } - Assert.Equal(NativeStatus.Eof, ReadApi.NextRowDecoded(handle, out _)); + Assert.Equal(NativeStatus.Eof, ReadApi.NextRowDecoded(cursor, out _)); } finally { @@ -764,8 +723,9 @@ public void NextRowDecoded_Should_Place_Every_Value_Inside_The_Row_Allocation() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(handle, out NativeRow row)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(cursor, out NativeRow row)); try { Assert.Equal(3, row.CellCount); @@ -809,8 +769,9 @@ public void NextRowDecoded_Should_NUL_Terminate_Every_Value() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(handle, out NativeRow row)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(cursor, out NativeRow row)); try { NativeRowCell cell = Marshal.PtrToStructure(row.Cells); @@ -835,7 +796,8 @@ public void NextRowDecoded_Should_Return_A_Null_Cells_Pointer_For_An_Empty_Row() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(handle, out NativeRow row)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowDecoded(cursor, out NativeRow row)); Assert.Equal(0, row.CellCount); Assert.Equal(IntPtr.Zero, row.Cells); @@ -866,12 +828,13 @@ public void NextRow_Should_Reserve_The_Row_When_The_Buffer_Is_Too_Small() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] tiny = new byte[3]; - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(handle, tiny, out int required)); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(cursor, tiny, out int required)); Assert.True(required > 3); byte[] big = new byte[required]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, big, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, big, out int written)); Assert.Equal(required, written); Assert.Equal("name", DecodeRow(big.AsSpan(0, written))[0].Value); } @@ -888,9 +851,10 @@ public void NextRow_Should_Read_Every_Row_Of_The_Xlsx_Fixture() Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[1 << 20]; int rows = 0; - while (ReadApi.NextRow(handle, buffer, out int written) == NativeStatus.Ok) + while (ReadApi.NextRow(cursor, buffer, out int written) == NativeStatus.Ok) { Assert.True(written >= 4); rows++; @@ -905,7 +869,7 @@ public void NextRow_Should_Read_Every_Row_Of_The_Xlsx_Fixture() } [Fact] - public void MovingToASheetChangesWhatSheetNameAndNextRowReport() + public void Cursors_On_Different_Sheets_Should_Each_Read_Their_Own_Sheet() { using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( [ @@ -915,24 +879,19 @@ public void MovingToASheetChangesWhatSheetNameAndNextRowReport() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { + using NativeRowCursor second = OpenRows(handle, 1); + using NativeRowCursor first = OpenRows(handle, 0); Span name = stackalloc byte[64]; byte[] row = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int firstLength)); - Assert.Equal("First", Encoding.UTF8.GetString(name[..firstLength])); - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 1)); - Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int secondLength)); + Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 1, name, out int secondLength)); Assert.Equal("Second", Encoding.UTF8.GetString(name[..secondLength])); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, row, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(second, row, out int written)); Assert.Equal("10", DecodeRow(row.AsSpan(0, written))[0].Value); - Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 0, name, out int atLength)); - Assert.Equal("First", Encoding.UTF8.GetString(name[..atLength])); - - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 0)); - Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int backLength)); - Assert.Equal("First", Encoding.UTF8.GetString(name[..backLength])); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, row, out written)); + Assert.Equal(NativeStatus.Ok, ReadApi.SheetNameAt(handle, 0, name, out int firstLength)); + Assert.Equal("First", Encoding.UTF8.GetString(name[..firstLength])); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(first, row, out written)); Assert.Equal("1", DecodeRow(row.AsSpan(0, written))[0].Value); } finally @@ -942,42 +901,18 @@ public void MovingToASheetChangesWhatSheetNameAndNextRowReport() } [Fact] - public void MovingToASheetOutOfRangeFailsAndKeepsTheCurrentSheet() - { - using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( - [ - ("First", """1"""), - ("Second", """10"""), - ]); - Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); - try - { - Span name = stackalloc byte[64]; - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 1)); - - Assert.Equal(NativeStatus.Error, ReadApi.MoveToSheet(handle, 99)); - Assert.Equal(NativeStatus.Error, ReadApi.MoveToSheet(handle, -1)); - Assert.Equal(NativeStatus.Ok, ReadApi.SheetName(handle, name, out int length)); - Assert.Equal("Second", Encoding.UTF8.GetString(name[..length])); - } - finally - { - ReadApi.Close(handle); - } - } - - [Fact] - public void NextRow_Should_Restart_After_MoveToSheet() + public void A_Second_Cursor_On_The_Same_Sheet_Should_Start_From_The_Top() { Assert.Equal(NativeStatus.Ok, OpenPath(XlsxFixture, NativeFormat.Auto, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[1 << 20]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int firstPass)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int firstPass)); - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 0)); + using NativeRowCursor again = OpenRows(handle, 0); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int secondPass)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(again, buffer, out int secondPass)); Assert.Equal(firstPass, secondPass); } finally @@ -999,10 +934,11 @@ public void NextRow_Should_Serialize_Xlsb_Numeric_Cells_With_A_Nonempty_Value() Assert.Equal(NativeStatus.Ok, OpenPath(XlsbFixture, NativeFormat.Xlsb, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[1 << 20]; bool foundNumericCell = false; - while (ReadApi.NextRow(handle, buffer, out int written) == NativeStatus.Ok) + while (ReadApi.NextRow(cursor, buffer, out int written) == NativeStatus.Ok) { List cells = DecodeRow(buffer.AsSpan(0, written)); foreach (DecodedCell cell in cells) @@ -1035,7 +971,8 @@ public void ReadAllDecoded_Should_Return_Every_Remaining_Row_In_One_Call() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(handle, out NativeRows rows)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(cursor, out NativeRows rows)); try { Assert.Equal(3, rows.RowCount); @@ -1071,10 +1008,11 @@ public void ReadAllDecoded_Should_Return_Zero_Rows_At_End_Of_Sheet() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(handle, out NativeRows first)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(cursor, out NativeRows first)); ReadApi.FreeRows(ref first); - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(handle, out NativeRows second)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllDecoded(cursor, out NativeRows second)); Assert.Equal(0, second.RowCount); Assert.Equal(IntPtr.Zero, second.Rows); ReadApi.FreeRows(ref second); @@ -1105,7 +1043,8 @@ public void ReadAllDecoded_Should_Free_Already_Decoded_Rows_When_A_Later_Row_Fai NativeHandle handle = NativeHandle_Create(workbook); try { - int status = ReadApi.ReadAllDecoded(handle, out NativeRows rows); + using NativeRowCursor cursor = OpenRows(handle); + int status = ReadApi.ReadAllDecoded(cursor, out NativeRows rows); Assert.Equal(NativeStatus.Error, status); Assert.Equal(0, rows.RowCount); @@ -1133,8 +1072,9 @@ public void ReadAllBlob_Should_Contain_Every_Remaining_Row() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { + using NativeRowCursor cursor = OpenRows(handle); byte[] buffer = new byte[8192]; - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, buffer, out int written)); List> rows = DecodeAllRowsBlob(buffer.AsSpan(0, written)); Assert.Equal(3, rows.Count); @@ -1158,10 +1098,11 @@ public void ReadAllBlob_Should_Include_A_Row_Already_Pending_From_NextRow() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(handle, Span.Empty, out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(cursor, Span.Empty, out _)); byte[] buffer = new byte[8192]; - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, buffer, out int written)); List> rows = DecodeAllRowsBlob(buffer.AsSpan(0, written)); Assert.Equal(3, rows.Count); @@ -1184,11 +1125,12 @@ public void ReadAllBlob_Should_Not_Lose_Rows_When_The_Buffer_Is_Too_Small() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(handle, Span.Empty, out int required)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(cursor, Span.Empty, out int required)); Assert.True(required > 0); byte[] big = new byte[required]; - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, big, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, big, out int written)); Assert.Equal(required, written); List> rows = DecodeAllRowsBlob(big.AsSpan(0, written)); @@ -1210,10 +1152,11 @@ public void ReadAllBlob_Should_Return_Zero_Rows_At_End_Of_Sheet() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, new byte[4096], out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, new byte[4096], out _)); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, buffer, out int written)); Assert.Equal(0, BitConverter.ToInt32(buffer.AsSpan(0, written))); } finally @@ -1224,7 +1167,7 @@ public void ReadAllBlob_Should_Return_Zero_Rows_At_End_Of_Sheet() } [Fact] - public void ReadAllBlob_Should_Drop_Pending_Bytes_On_Sheet_Change() + public void ReadAllBlob_Pending_Bytes_Should_Survive_A_Cursor_Opened_On_Another_Sheet() { using MemoryStream ms = WorkbookBuilder.BuildMultiSheet( [ @@ -1234,15 +1177,16 @@ public void ReadAllBlob_Should_Drop_Pending_Bytes_On_Sheet_Change() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(handle, Span.Empty, out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(cursor, Span.Empty, out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.MoveToSheet(handle, 1)); + using NativeRowCursor other = OpenRows(handle, 1); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.ReadAllBlob(cursor, buffer, out int written)); List> rows = DecodeAllRowsBlob(buffer.AsSpan(0, written)); Assert.Single(rows); - Assert.Equal("99", rows[0][0].Value); + Assert.Equal("1", rows[0][0].Value); } finally { @@ -1382,13 +1326,14 @@ public void InferSchema_Should_Not_Disturb_The_Row_Cursor() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema schema)); ReadApi.FreeSchema(ref schema); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); Assert.Equal("Alice", DecodeRow(buffer.AsSpan(0, written))[0].Value); } finally diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.RowView.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.RowView.cs index a59737ab..5f10a0b5 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.RowView.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.RowView.cs @@ -27,13 +27,14 @@ public void NextRowView_Should_Expose_A_Csv_Row_Exactly() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow header)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow header)); Assert.Equal([new DecodedCell(0, 1, "name"), new DecodedCell(1, 1, "qty")], ReadView(header)); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow data)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow data)); Assert.Equal(["widget", "7"], ReadView(data).Select(cell => cell.Value), StringComparer.Ordinal); - Assert.Equal(NativeStatus.Eof, ReadApi.NextRowView(handle, out NativeRow end)); + Assert.Equal(NativeStatus.Eof, ReadApi.NextRowView(cursor, out NativeRow end)); Assert.Equal(IntPtr.Zero, end.Cells); } finally @@ -53,16 +54,18 @@ public void NextRowView_Should_Match_NextRow_On_Every_Row(int format) Assert.Equal(NativeStatus.Ok, OpenPath(fixture, format, out NativeHandle? viewHandle)); try { + using NativeRowCursor viewCursor = OpenRows(viewHandle); + using NativeRowCursor blobCursor = OpenRows(blobHandle); byte[] buffer = new byte[1 << 20]; int rows = 0; - while (ReadApi.NextRow(blobHandle, buffer, out int written) == NativeStatus.Ok) + while (ReadApi.NextRow(blobCursor, buffer, out int written) == NativeStatus.Ok) { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(viewHandle, out NativeRow row)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(viewCursor, out NativeRow row)); Assert.Equal(DecodeRow(buffer.AsSpan(0, written)), ReadView(row)); rows++; } Assert.True(rows > 1); - Assert.Equal(NativeStatus.Eof, ReadApi.NextRowView(viewHandle, out _)); + Assert.Equal(NativeStatus.Eof, ReadApi.NextRowView(viewCursor, out _)); } finally { @@ -79,11 +82,12 @@ public void NextRowView_Should_Take_Over_A_Row_Pending_From_The_Blob_API() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(handle, Span.Empty, out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.NextRow(cursor, Span.Empty, out _)); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow header)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow header)); Assert.Equal(["name", "qty"], ReadView(header).Select(cell => cell.Value), StringComparer.Ordinal); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow data)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow data)); Assert.Equal(["widget", "7"], ReadView(data).Select(cell => cell.Value), StringComparer.Ordinal); } finally @@ -103,13 +107,14 @@ public void NextRowView_Should_Grow_For_A_Row_Wider_Than_Its_Buffers() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow first)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow first)); Assert.Equal("a", ReadView(first)[0].Value); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow many)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow many)); Assert.Equal(wide, ReadView(many).Select(cell => cell.Value), StringComparer.Ordinal); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow large)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow large)); Assert.Equal(big, ReadView(large)[0].Value); - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow last)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow last)); Assert.Equal("b", ReadView(last)[0].Value); } finally @@ -126,7 +131,8 @@ public void NextRowView_Should_Return_A_Null_Cells_Pointer_For_An_Empty_Row() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(handle, out NativeRow row)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(cursor, out NativeRow row)); Assert.Equal(0, row.CellCount); Assert.Equal(IntPtr.Zero, row.Cells); } @@ -146,7 +152,7 @@ public void NextRowView_Should_Reject_A_Null_Handle() [Fact] public unsafe void NextRowView_Export_Should_Reject_A_Null_Out_Row() { - int status = ((delegate* unmanaged)&Exports.NextRowView)(0, null); + int status = ((delegate* unmanaged)&Exports.RowsNextView)(0, null); Assert.Equal(NativeStatus.InvalidArgument, status); } } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs index 689b0380..0d0130d7 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs @@ -3,6 +3,7 @@ using ExcelReader.Native.Arrow; using ExcelReader.Native.Reading; using ExcelReader.Native.Typed; +using ExcelReader.Tests.Reader; namespace ExcelReader.Tests.Native { @@ -71,5 +72,145 @@ public void ParseTyped_Should_Reject_Sheet_One_Of_A_Csv_Even_When_Asked_To_Run_I Assert.Equal(NativeStatus.Error, status); Assert.Equal(IntPtr.Zero, table.Columns); } + + private static List DrainFirstColumn(NativeRowCursor cursor) + { + List values = []; + byte[] buffer = new byte[4096]; + while (true) + { + int status = ReadApi.NextRow(cursor, buffer, out int written); + if (status == NativeStatus.Eof) + { + return values; + } + Assert.Equal(NativeStatus.Ok, status); + values.Add(DecodeRow(buffer.AsSpan(0, written))[0].Value); + } + } + + [Fact] + public async Task OpenRows_Should_Read_The_Sheet_At_The_Given_Index() + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + using NativeRowCursor second = OpenRows(handle, 1); + using NativeRowCursor first = OpenRows(handle, 0); + + Assert.Equal(["b1", "b2", "b3"], DrainFirstColumn(second)); + Assert.Equal(["a1", "a2"], DrainFirstColumn(first)); + } + + [Fact] + public async Task Two_Cursors_On_The_Same_Sheet_Should_Each_Return_Every_Row() + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + using NativeRowCursor one = OpenRows(handle, 1); + using NativeRowCursor two = OpenRows(handle, 1); + byte[] buffer = new byte[4096]; + + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(one, buffer, out _)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRowView(two, out _)); + + Assert.Equal(["b2", "b3"], DrainFirstColumn(one)); + Assert.Equal(["b2", "b3"], DrainFirstColumn(two)); + } + + [Theory] + [InlineData(-1, NativeStatus.InvalidArgument)] + [InlineData(2, NativeStatus.Error)] + public async Task OpenRows_And_SheetVisibilityAt_Should_Reject_An_Index_Out_Of_Range(int sheet, int expected) + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + + Assert.Equal(expected, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? cursor)); + Assert.Null(cursor); + Assert.Equal(expected, ReadApi.SheetVisibilityAt(handle, sheet, out _)); + } + + [Fact] + public async Task A_Cursor_Opened_But_Not_Yet_Read_Should_Read_Every_Row_After_Its_Workbook_Is_Closed() + { + NativeHandle handle = await OpenTwoSheetsAsync(); + using NativeRowCursor cursor = OpenRows(handle, 1); + + Assert.Equal(NativeStatus.Ok, ReadApi.Close(handle)); + + Assert.Equal(["b1", "b2", "b3"], DrainFirstColumn(cursor)); + Assert.Equal(NativeStatus.Error, ReadApi.OpenRows(handle, 0, out _)); + } + + [Theory] + [InlineData("Second", 1)] + [InlineData("sEcOnD", 1)] + [InlineData("First", 0)] + [InlineData("Missing", -1)] + [InlineData("", -1)] + public async Task SheetIndex_Should_Match_Names_Without_Regard_To_Case_And_Report_Minus_One_When_Absent(string name, int expected) + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + + Assert.Equal(NativeStatus.Ok, ReadApi.SheetIndex(handle, Encoding.UTF8.GetBytes(name), out int index)); + + Assert.Equal(expected, index); + } + + [Fact] + public void SheetIndex_Should_Find_The_Unnamed_Sheet_Of_A_Csv() + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory("a,b\n1,2\n"u8, NativeFormat.Csv, out NativeHandle? handle)); + using NativeHandle live = handle!; + + Assert.Equal(NativeStatus.Ok, ReadApi.SheetIndex(live, [], out int index)); + + Assert.Equal(0, index); + } + + [Fact] + public void SheetVisibilityAt_Should_Report_Visible_Hidden_And_VeryHidden() + { + byte[] xlsx = SheetVisibilityTests.BuildXlsx(null, "hidden", "veryHidden"); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(xlsx, NativeFormat.Xlsx, out NativeHandle? handle)); + using NativeHandle live = handle!; + + for (int i = 0; i < 3; i++) + { + Assert.Equal(NativeStatus.Ok, ReadApi.SheetVisibilityAt(live, i, out int visibility)); + Assert.Equal(i, visibility); + } + } + + [Fact] + public async Task A_Handle_Id_Should_Resolve_Only_As_Its_Own_Kind() + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + NativeRowCursor cursor = OpenRows(handle); + nint workbookId = NativeHandleTable.Register(handle); + nint cursorId = NativeHandleTable.Register(cursor); + + Assert.Null(Exports.ResolveRows(workbookId)); + Assert.Null(Exports.Resolve(cursorId)); + Assert.False(Exports.TryFree(cursorId, out _)); + Assert.Same(cursor, Exports.ResolveRows(cursorId)); + + Assert.True(NativeHandleTable.TryUnregister(workbookId, out NativeHandle? _)); + Assert.True(NativeHandleTable.TryUnregister(cursorId, out NativeRowCursor? _)); + cursor.Dispose(); + } + + [Fact] + public async Task A_Cursor_Should_Close_Once_With_A_Read_All_Result_Still_Pending() + { + using NativeHandle handle = await OpenTwoSheetsAsync(); + NativeRowCursor cursor = OpenRows(handle, 1); + nint id = NativeHandleTable.Register(cursor); + Assert.Equal(NativeStatus.BufferTooSmall, ReadApi.ReadAllBlob(cursor, Span.Empty, out int needed)); + Assert.True(needed > 0); + + Assert.True(NativeHandleTable.TryUnregister(id, out NativeRowCursor? first)); + Assert.Equal(NativeStatus.Ok, ReadApi.CloseRows(first)); + + Assert.False(NativeHandleTable.TryUnregister(id, out NativeRowCursor? second)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.CloseRows(second)); + } } } diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs index d183ea98..77a73c7b 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Typed.cs @@ -472,14 +472,15 @@ public void ParseTyped_Should_Not_Disturb_The_Row_Cursor() Assert.Equal(NativeStatus.Ok, OpenPath(path, NativeFormat.Csv, out NativeHandle? handle)); try { - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, new byte[4096], out _)); + using NativeRowCursor cursor = OpenRows(handle); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, new byte[4096], out _)); NativeColumnSpec[] specs = [new() { Names = ["name"], Type = NativeColumnType.String }]; Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(handle, 0, specs, headerRow: 1, out NativeTable table)); TypedApi.FreeTable(ref table); byte[] buffer = new byte[4096]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(handle, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); Assert.Equal("first", DecodeRow(buffer.AsSpan(0, written))[0].Value); } finally diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.cs index 98f062ec..1c57245a 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.cs @@ -19,6 +19,12 @@ internal static int OpenPath(string path, int format, out NativeHandle? handle) return ReadApi.OpenFile(Encoding.UTF8.GetBytes(path), format, out handle); } + internal static NativeRowCursor OpenRows(NativeHandle? handle, int sheet = 0) + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? cursor)); + return cursor!; + } + private static NativeOpenOptionsRaw DefaultRawOptions() { return new NativeOpenOptionsRaw { StructSize = Marshal.SizeOf() }; diff --git a/tests/ExcelReader.Tests/Native/NativeWriterHandleTests.cs b/tests/ExcelReader.Tests/Native/NativeWriterHandleTests.cs index 645633bd..e7892075 100644 --- a/tests/ExcelReader.Tests/Native/NativeWriterHandleTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeWriterHandleTests.cs @@ -52,8 +52,9 @@ public void OpenWriteHandle_Should_Round_Trip_A_Streamed_Row_Through_OpenFile(in Assert.NotNull(reader); try { + using NativeRowCursor cursor = NativeApiTests.OpenRows(reader); Span buffer = stackalloc byte[256]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(reader, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); string row = Encoding.UTF8.GetString(buffer[..written]); Assert.Contains("uma", row, StringComparison.Ordinal); Assert.Contains("3", row, StringComparison.Ordinal); @@ -345,8 +346,9 @@ public void GetWriteHandleBytes_Should_Round_Trip_A_Streamed_Row_Through_OpenMem Assert.NotNull(reader); try { + using NativeRowCursor cursor = NativeApiTests.OpenRows(reader); Span buffer = stackalloc byte[256]; - Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(reader, buffer, out int written)); + Assert.Equal(NativeStatus.Ok, ReadApi.NextRow(cursor, buffer, out int written)); string row = Encoding.UTF8.GetString(buffer[..written]); Assert.Contains("uma", row, StringComparison.Ordinal); Assert.Contains("3", row, StringComparison.Ordinal); diff --git a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs index d81988aa..a38b2c78 100644 --- a/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs +++ b/tests/ExcelReader.Tests/Reader/SheetVisibilityTests.cs @@ -23,7 +23,7 @@ public class SheetVisibilityTests private const string PkgRel = "http://schemas.openxmlformats.org/package/2006/relationships"; /// Builds an XLSX whose sheets carry the given state attribute text; a null entry omits it. - private static byte[] BuildXlsx(params string?[] states) + internal static byte[] BuildXlsx(params string?[] states) { var sheetXml = new string[states.Length]; var relXml = new string[states.Length]; From e28a01a70cd90a3d68a7503099fd0b9ae8a7fad8 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:06:09 -0300 Subject: [PATCH 24/49] feat(native)!: fold xl_open_file_ex and xl_open_memory_ex into the open functions xl_open_file and xl_open_memory take the options pointer; NULL means the defaults. Co-Authored-By: Claude Haiku 4.5 --- rust/excelreader/excelreader.def | 2 - src/ExcelReader.Native/Exports.Reading.cs | 32 ++------------ .../Reading/ReadApi.Open.cs | 4 +- .../include/excelreader.def | 2 - src/ExcelReader.Native/include/excelreader.h | 42 ++++++++++--------- .../Native/NativeApiTests.Reading.cs | 28 ++++++------- .../Native/NativeApiTests.cs | 2 +- 7 files changed, 43 insertions(+), 69 deletions(-) diff --git a/rust/excelreader/excelreader.def b/rust/excelreader/excelreader.def index 37a46905..29729e2d 100644 --- a/rust/excelreader/excelreader.def +++ b/rust/excelreader/excelreader.def @@ -1,9 +1,7 @@ EXPORTS xl_abi_version xl_open_file - xl_open_file_ex xl_open_memory - xl_open_memory_ex xl_close xl_sheet_count xl_sheet_name_at diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index c02726ab..2011274a 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -6,19 +6,7 @@ namespace ExcelReader.Native internal static unsafe partial class Exports { [UnmanagedCallersOnly(EntryPoint = "xl_open_file")] - public static int OpenFile(byte* path, int pathLength, int format, nint* outHandle) - { - if (!IsValidOpenRequest(path, pathLength, outHandle)) - { - return NativeStatus.InvalidArgument; - } - - int status = ReadApi.OpenFile(new ReadOnlySpan(path, pathLength), format, out NativeHandle? handle); - return RegisterOpened(status, handle, outHandle); - } - - [UnmanagedCallersOnly(EntryPoint = "xl_open_file_ex")] - public static int OpenFileEx(byte* path, int pathLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) + public static int OpenFile(byte* path, int pathLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) { if (!IsValidOpenRequest(path, pathLength, outHandle)) { @@ -29,24 +17,12 @@ public static int OpenFileEx(byte* path, int pathLength, int format, NativeOpenO { return NativeStatus.InvalidArgument; } - int status = ReadApi.OpenFileEx(new ReadOnlySpan(path, pathLength), format, rawOptions, out NativeHandle? handle); + int status = ReadApi.OpenFile(new ReadOnlySpan(path, pathLength), format, rawOptions, out NativeHandle? handle); return RegisterOpened(status, handle, outHandle); } [UnmanagedCallersOnly(EntryPoint = "xl_open_memory")] - public static int OpenMemory(byte* data, int dataLength, int format, nint* outHandle) - { - if (!IsValidOpenRequest(data, dataLength, outHandle)) - { - return NativeStatus.InvalidArgument; - } - - int status = ReadApi.OpenMemory(new ReadOnlySpan(data, dataLength), format, out NativeHandle? handle); - return RegisterOpened(status, handle, outHandle); - } - - [UnmanagedCallersOnly(EntryPoint = "xl_open_memory_ex")] - public static int OpenMemoryEx(byte* data, int dataLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) + public static int OpenMemory(byte* data, int dataLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) { if (!IsValidOpenRequest(data, dataLength, outHandle)) { @@ -57,7 +33,7 @@ public static int OpenMemoryEx(byte* data, int dataLength, int format, NativeOpe { return NativeStatus.InvalidArgument; } - int status = ReadApi.OpenMemoryEx(new ReadOnlySpan(data, dataLength), format, rawOptions, out NativeHandle? handle); + int status = ReadApi.OpenMemory(new ReadOnlySpan(data, dataLength), format, rawOptions, out NativeHandle? handle); return RegisterOpened(status, handle, outHandle); } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Open.cs b/src/ExcelReader.Native/Reading/ReadApi.Open.cs index e3fb428c..c01336f6 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Open.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Open.cs @@ -117,7 +117,7 @@ private static bool IsKnownFormat(int format) return format is >= NativeFormat.Auto and <= NativeFormat.Csv; } - internal static int OpenFileEx(ReadOnlySpan utf8Path, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) + internal static int OpenFile(ReadOnlySpan utf8Path, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) { handle = null; if (!TryDecodeOpenOptions(rawOptions, out NativeOpenOptions? options, out string? error)) @@ -128,7 +128,7 @@ internal static int OpenFileEx(ReadOnlySpan utf8Path, int format, NativeOp return OpenFile(utf8Path, format, out handle, options); } - internal static int OpenMemoryEx(ReadOnlySpan data, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) + internal static int OpenMemory(ReadOnlySpan data, int format, NativeOpenOptionsRaw? rawOptions, out NativeHandle? handle) { handle = null; if (!TryDecodeOpenOptions(rawOptions, out NativeOpenOptions? options, out string? error)) diff --git a/src/ExcelReader.Native/include/excelreader.def b/src/ExcelReader.Native/include/excelreader.def index 37a46905..29729e2d 100644 --- a/src/ExcelReader.Native/include/excelreader.def +++ b/src/ExcelReader.Native/include/excelreader.def @@ -1,9 +1,7 @@ EXPORTS xl_abi_version xl_open_file - xl_open_file_ex xl_open_memory - xl_open_memory_ex xl_close xl_sheet_count xl_sheet_name_at diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index c9cfd06e..87381caf 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -56,10 +56,6 @@ typedef struct xl_row { xl_row_cell* cells; } xl_row; -int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, xl_workbook** out_handle); - -int32_t xl_open_memory(const uint8_t* data, int32_t data_len, int32_t format, xl_workbook** out_handle); - #define XL_OPT_DEFAULT 0 #define XL_OPT_FALSE 1 #define XL_OPT_TRUE 2 @@ -67,30 +63,36 @@ int32_t xl_open_memory(const uint8_t* data, int32_t data_len, int32_t format, xl typedef struct xl_open_options { int32_t struct_size; - int32_t csv_sniff_dialect; - int32_t csv_delimiter; - int32_t csv_quote; - int32_t csv_detect_bom; - int32_t csv_max_cell_bytes; - int32_t csv_intern_strings; + int32_t csv_sniff_dialect; + int32_t csv_delimiter; + int32_t csv_quote; + int32_t csv_detect_bom; + int32_t csv_max_cell_bytes; + int32_t csv_intern_strings; - int64_t max_total_decompressed_bytes; - int32_t max_cell_bytes; - int64_t max_shared_string_bytes; - int32_t max_zip_entries; - int32_t prefetch_decompression; - int32_t intern_strings; + int64_t max_total_decompressed_bytes; + int32_t max_cell_bytes; + int64_t max_shared_string_bytes; + int32_t max_zip_entries; + int32_t prefetch_decompression; + int32_t intern_strings; const uint8_t* password; int32_t password_len; } xl_open_options; -int32_t xl_open_file_ex(const uint8_t* path, int32_t path_len, int32_t format, - const xl_open_options* options, xl_workbook** out_handle); +/* options may be NULL for the defaults. When it is not NULL, set options->struct_size to + sizeof(xl_open_options) first. xl_open_memory copies data, so the buffer may be released as soon + as the call returns. */ +int32_t xl_open_file(const uint8_t* path, int32_t path_len, int32_t format, + const xl_open_options* options, xl_workbook** out_handle); -int32_t xl_open_memory_ex(const uint8_t* data, int32_t data_len, int32_t format, - const xl_open_options* options, xl_workbook** out_handle); +int32_t xl_open_memory(const uint8_t* data, int32_t data_len, int32_t format, + const xl_open_options* options, xl_workbook** out_handle); +/* Cursors, typed readers and Arrow streams opened from this handle keep working after xl_close; the + workbook's resources are released when the last of them is closed. Any later call that takes this + handle returns XL_INVALID_HANDLE. */ int32_t xl_close(xl_workbook* handle); /* A workbook handle may be used from several threads at once. index and sheet are zero-based: diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index 2991a989..7b74cf93 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -227,7 +227,7 @@ public void OpenMemory_Should_Open_Xlsx_From_A_Copy_Of_The_Bytes() [Fact] public void OpenFileEx_With_Null_Options_Behaves_Like_OpenFile() { - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, null, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, null, out NativeHandle? handle); Assert.Equal(NativeStatus.Ok, status); Assert.NotNull(handle); @@ -239,7 +239,7 @@ public void OpenMemoryEx_With_Null_Options_Behaves_Like_OpenMemory() { byte[] bytes = File.ReadAllBytes(XlsxFixture); - int status = ReadApi.OpenMemoryEx(bytes, NativeFormat.Auto, null, out NativeHandle? handle); + int status = ReadApi.OpenMemory(bytes, NativeFormat.Auto, null, out NativeHandle? handle); Assert.Equal(NativeStatus.Ok, status); Assert.NotNull(handle); @@ -253,7 +253,7 @@ public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Delimiter(int delimiter) { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvDelimiter = delimiter }; - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Csv, options, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Csv, options, out NativeHandle? handle); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Null(handle); @@ -264,7 +264,7 @@ public void OpenFileEx_Rejects_A_Negative_Numeric_Option() { NativeOpenOptionsRaw options = DefaultRawOptions() with { MaxZipEntries = -5 }; - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, options, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, options, out NativeHandle? handle); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Null(handle); @@ -275,7 +275,7 @@ public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Sniff_Dialect_State() { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvSniffDialect = 99 }; - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Csv, options, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Csv, options, out NativeHandle? handle); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Null(handle); @@ -289,7 +289,7 @@ public void OpenFileEx_Applies_An_Explicit_Csv_Delimiter() try { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvDelimiter = (byte)';' }; - Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenFile(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { using NativeRowCursor cursor = OpenRows(handle); @@ -319,7 +319,7 @@ public void OpenFileEx_Sniffs_The_Csv_Dialect_When_Requested() try { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvSniffDialect = NativeOptionState.True }; - Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenFile(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { using NativeRowCursor cursor = OpenRows(handle); @@ -346,7 +346,7 @@ public void OpenFileEx_Applies_A_Tiny_Max_Total_Decompressed_Bytes_To_A_Real_Xls { NativeOpenOptionsRaw options = DefaultRawOptions() with { MaxTotalDecompressedBytes = 1 }; - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Xlsx, options, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Xlsx, options, out NativeHandle? handle); Assert.Equal(NativeStatus.Error, status); Assert.Null(handle); @@ -360,7 +360,7 @@ public void OpenFileEx_Applies_A_Tiny_Csv_Max_Cell_Bytes() try { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvMaxCellBytes = 4 }; - Assert.Equal(NativeStatus.Ok, ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenFile(Encoding.UTF8.GetBytes(path), NativeFormat.Csv, options, out NativeHandle? handle)); try { using NativeRowCursor cursor = OpenRows(handle); @@ -387,7 +387,7 @@ public void Should_Open_When_Password_Passed_Across_Abi() { Marshal.Copy(pw, 0, pointer, pw.Length); NativeOpenOptionsRaw options = DefaultRawOptions() with { Password = pointer, PasswordLen = pw.Length }; - int status = ReadApi.OpenFileEx( + int status = ReadApi.OpenFile( Encoding.UTF8.GetBytes(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")), NativeFormat.Auto, options, out NativeHandle? handle); @@ -405,7 +405,7 @@ public void Should_Open_When_Password_Passed_Across_Abi() public void Should_Return_PasswordRequired_When_No_Password_Across_Abi() { NativeOpenOptionsRaw options = DefaultRawOptions(); - int status = ReadApi.OpenFileEx( + int status = ReadApi.OpenFile( Encoding.UTF8.GetBytes(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")), NativeFormat.Auto, options, out NativeHandle? handle); @@ -422,7 +422,7 @@ public void Should_Return_PasswordIncorrect_When_Password_Wrong_Across_Abi() { Marshal.Copy(pw, 0, pointer, pw.Length); NativeOpenOptionsRaw options = DefaultRawOptions() with { Password = pointer, PasswordLen = pw.Length }; - int status = ReadApi.OpenFileEx( + int status = ReadApi.OpenFile( Encoding.UTF8.GetBytes(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")), NativeFormat.Auto, options, out NativeHandle? handle); @@ -439,7 +439,7 @@ public void Should_Return_PasswordIncorrect_When_Password_Wrong_Across_Abi() public void Should_Reject_When_Password_Len_Is_Negative() { NativeOpenOptionsRaw options = DefaultRawOptions() with { Password = 1, PasswordLen = -1 }; - int status = ReadApi.OpenFileEx( + int status = ReadApi.OpenFile( Encoding.UTF8.GetBytes(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")), NativeFormat.Auto, options, out NativeHandle? handle); @@ -451,7 +451,7 @@ public void Should_Reject_When_Password_Len_Is_Negative() public void Should_Reject_When_Struct_Size_Is_Stale() { NativeOpenOptionsRaw options = DefaultRawOptions() with { StructSize = Marshal.SizeOf() - 8 }; - int status = ReadApi.OpenFileEx( + int status = ReadApi.OpenFile( Encoding.UTF8.GetBytes(EncryptedFixtures.Path_("agile-aes256-sha512.xlsx")), NativeFormat.Auto, options, out NativeHandle? handle); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.cs index 1c57245a..81c8b988 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.cs @@ -304,7 +304,7 @@ public void OpenFileEx_With_An_Unrecognized_Struct_Size_Is_Invalid_Argument() { NativeOpenOptionsRaw options = DefaultRawOptions() with { StructSize = 1 }; - int status = ReadApi.OpenFileEx(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, options, out NativeHandle? handle); + int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, options, out NativeHandle? handle); Assert.Equal(NativeStatus.InvalidArgument, status); Assert.Null(handle); From bb17a9ca2f136d096737836f1760198f3152dce7 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:09:39 -0300 Subject: [PATCH 25/49] test(native): read every sheet of one workbook handle in parallel Cursors and typed parses over all sheets of one handle match a sequential read in XLSX, XLSB and XLS, and a cursor reads to the end after its workbook id is closed. Co-Authored-By: Claude Haiku 4.5 --- .../Native/NativeApiConcurrencyTests.cs | 93 +++++++++++++++++++ .../Reader/ConcurrentSheetTests.cs | 6 +- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs index 1b443ce2..3b6a06bf 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs @@ -1,6 +1,9 @@ +using System.Runtime.InteropServices; using System.Text; using ExcelReader.Native; using ExcelReader.Native.Reading; +using ExcelReader.Native.Typed; +using ExcelReader.Tests.Reader; namespace ExcelReader.Tests.Native { @@ -63,5 +66,95 @@ public Task LastErrorIsPerThreadNotSharedAcrossHandles() return Task.WhenAll(tasks); } + + private static List ReadSheet(NativeHandle handle, int sheet) + { + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? opened)); + using NativeRowCursor cursor = opened!; + List lines = []; + while (true) + { + int status = ReadApi.NextRowView(cursor, out NativeRow row); + if (status == NativeStatus.Eof) + { + return lines; + } + Assert.Equal(NativeStatus.Ok, status); + StringBuilder line = new(); + int cellSize = Marshal.SizeOf(); + for (int i = 0; i < row.CellCount; i++) + { + NativeRowCell cell = Marshal.PtrToStructure(IntPtr.Add(row.Cells, i * cellSize)); + line.Append(Marshal.PtrToStringUTF8(cell.Value, cell.ValueLength)).Append('|'); + } + lines.Add(line.ToString()); + } + } + + [Theory] + [InlineData(ConcurrentSheetTests.Format.Xlsx, NativeFormat.Xlsx)] + [InlineData(ConcurrentSheetTests.Format.Xlsb, NativeFormat.Xlsb)] + [InlineData(ConcurrentSheetTests.Format.Xls, NativeFormat.Xls)] + public void CursorsOverEverySheetOfOneWorkbookReadTheSameRowsInParallelAsInSequence(ConcurrentSheetTests.Format format, int nativeFormat) + { + byte[] bytes = ConcurrentSheetTests.Build(format); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, nativeFormat, out NativeHandle? handle)); + using NativeHandle live = handle!; + List[] sequential = new List[ConcurrentSheetTests.Sheets]; + for (int s = 0; s < sequential.Length; s++) + { + sequential[s] = ReadSheet(live, s); + Assert.Equal(ConcurrentSheetTests.Rows, sequential[s].Count); + } + + List[] parallel = new List[ConcurrentSheetTests.Sheets * 2]; + Parallel.For(0, parallel.Length, i => parallel[i] = ReadSheet(live, i % ConcurrentSheetTests.Sheets)); + + for (int i = 0; i < parallel.Length; i++) + { + Assert.Equal(sequential[i % ConcurrentSheetTests.Sheets], parallel[i]); + } + } + + [Fact] + public void TypedParsesOfEverySheetOfOneWorkbookRunInParallel() + { + byte[] bytes = ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsx); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, NativeFormat.Xlsx, out NativeHandle? handle)); + using NativeHandle live = handle!; + long[] rowCounts = new long[ConcurrentSheetTests.Sheets]; + + Parallel.For(0, rowCounts.Length, s => + { + NativeColumnSpec[] specs = [new() { Index = 1, Type = NativeColumnType.Int64 }]; + Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, s, specs, headerRow: 0, out NativeTable table)); + rowCounts[s] = table.RowCount; + TypedApi.FreeTable(ref table); + }); + + Assert.All(rowCounts, count => Assert.Equal(ConcurrentSheetTests.Rows, count)); + } + + [Fact] + public void AClosedWorkbookIdStaysInvalidWhileItsCursorReadsToTheEnd() + { + byte[] bytes = ConcurrentSheetTests.Build(ConcurrentSheetTests.Format.Xlsb); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, NativeFormat.Xlsb, out NativeHandle? handle)); + nint id = NativeHandleTable.Register(handle!); + Assert.Equal(NativeStatus.Ok, ReadApi.OpenRows(Exports.Resolve(id), 3, out NativeRowCursor? opened)); + using NativeRowCursor cursor = opened!; + + Assert.True(Exports.TryFree(id, out NativeHandle? freed)); + Assert.Equal(NativeStatus.Ok, ReadApi.Close(freed)); + + Assert.Null(Exports.Resolve(id)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.OpenRows(Exports.Resolve(id), 0, out _)); + int rows = 0; + while (ReadApi.NextRowView(cursor, out _) == NativeStatus.Ok) + { + rows++; + } + Assert.Equal(ConcurrentSheetTests.Rows, rows); + } } } diff --git a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs index 91e0638f..ce4f6a23 100644 --- a/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs +++ b/tests/ExcelReader.Tests/Reader/ConcurrentSheetTests.cs @@ -25,8 +25,8 @@ public enum Source NonSeekableStream, } - private const int Sheets = 6; - private const int Rows = 3000; + internal const int Sheets = 6; + internal const int Rows = 3000; private readonly List _paths = []; @@ -74,7 +74,7 @@ private static void Fill(IWorkbookWriter workbook) workbook.End(); } - private static byte[] Build(Format format) + internal static byte[] Build(Format format) { using MemoryStream buffer = new(); switch (format) From df66fbf4bbb779b2838abc4b3602f61ac0008af3 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:13:49 -0300 Subject: [PATCH 26/49] docs(native): describe the v6 exports and their migration from v5 Co-Authored-By: Claude Haiku 4.5 --- docs/guide/migrating-to-v6.md | 30 +++++++++++++++++++++++++- src/ExcelReader.Native/README.md | 36 ++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index ec3caf59..89b0528b 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -175,4 +175,32 @@ The contract: ## Native bindings -The C ABI and the Python, Rust and C++ packages are unchanged in v6.0. +The C ABI changes with the library: `XL_ABI_VERSION` is 6. A workbook handle no longer has a current +sheet. Every read takes a zero-based sheet index, and rows are read through a cursor opened per +sheet, so one handle serves several threads. + +| v5 | v6 | +|---|---| +| `xl_open_file(path, len, format, &wb)` | `xl_open_file(path, len, format, NULL, &wb)` | +| `xl_open_file_ex`, `xl_open_memory_ex` | `xl_open_file`, `xl_open_memory` with the options pointer | +| `xl_move_to_sheet(wb, i)` | pass `i` to the read function | +| `xl_sheet_name(wb, ...)` | `xl_sheet_name_at(wb, i, ...)` | +| none | `xl_sheet_visibility_at(wb, i, &v)`, `xl_sheet_index(wb, name, len, &i)` | +| `xl_next_row(wb, ...)` | `xl_rows_open(wb, i, &rows)`, `xl_rows_next(rows, ...)`, `xl_rows_close(rows)` | +| `xl_next_row_view(wb, &row)` | `xl_rows_next_view(rows, &row)` | +| `xl_read_all_blob(wb, ...)`, `xl_read_all_decoded(wb, &out)` | `xl_rows_read_all_blob(rows, ...)`, `xl_rows_read_all_decoded(rows, &out)` | +| `xl_rows` (the decoded result struct) | `xl_rows_decoded`; `xl_rows` is now the cursor handle | +| `xl_parse_typed(wb, specs, n, header, &t)` | `xl_parse_typed(wb, i, specs, n, header, 1, &t)` | +| `xl_parse_typed_ex(wb, specs, n, header, dop, &t)` | `xl_parse_typed(wb, i, specs, n, header, dop, &t)` | +| `xl_parse_arrow`, `xl_parse_arrow_ex` | `xl_parse_arrow(wb, i, specs, n, header, dop, &array, &schema)` | +| `xl_parse_arrow_stream(wb, ...)`, `xl_typed_reader_open(wb, ...)` | the same, with `i` after `wb` | +| `xl_infer_schema(wb, header, sample, &s)` | `xl_infer_schema(wb, i, header, sample, 0, &s)` | +| `xl_infer_schema_ex(wb, header, sample, flags, &s)` | `xl_infer_schema(wb, i, header, sample, flags, &s)` | + +Behavior changes: + +- `xl_parse_typed` and `xl_parse_arrow` used to read sequentially; pass `1` as + `degree_of_parallelism` to keep that. `0` uses every processor on a CSV. +- Opening a second typed reader or Arrow stream on a workbook used to fail, and reading the workbook + another way used to invalidate an open one. Both now work: each read is independent. +- A cursor, typed reader or Arrow stream keeps working after `xl_close` on its workbook. diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index b12594eb..c254b127 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -25,10 +25,13 @@ example) silently drops its profile. That happened after the namespace refactor `` (after deleting `obj/.../native`) and look for `; No PGO data` in the listing header. -`tests/ExcelReader.NativePgoTrainer` runs the implementations of `xl_parse_typed`, `xl_next_row` and -`xl_next_row_view` over the 65K benchmark fixtures in XLSX, XLSB and CSV. Record it and convert the +`tests/ExcelReader.NativePgoTrainer` runs the implementations of `xl_parse_typed`, `xl_rows_next` and +`xl_rows_next_view` over the 65K benchmark fixtures in XLSX, XLSB and CSV. Record it and convert the trace (needs `dotnet-trace` and `dotnet-pgo`): +The checked-in profile predates the v6 row cursor: `ReadApi.NextRow` and `ReadApi.NextRowView` changed +signature, so they compile without profile data until the profile is regenerated and re-measured. + dotnet build tests/ExcelReader.NativePgoTrainer -c Release DOTNET_TieredPGO=1 DOTNET_ReadyToRun=0 DOTNET_TC_QuickJitForLoops=1 \ dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x1F000080018:5 -o pgo.nettrace \ @@ -46,16 +49,23 @@ may get worse, not only better. |---|---| | `NativeApi.cs`, `Reading/`, `Typed/`, `Arrow/`, `Csv/`, `Writer/` | Internal span-based implementation (`ReadApi`, `TypedApi`, `ArrowApi`, `CsvAggregateApi`, `WriteApi`). This is what the tests drive. | | `Exports*.cs` | `[UnmanagedCallersOnly]` pointer wrappers, one partial per feature. Keep logic out of here — it is untestable from managed code. | -| `NativeHandleTable.cs` | Maps the opaque handle ids callers see onto `NativeHandle` instances. Ids are never reissued after `xl_close`, so a stale handle stays invalid permanently. | +| `NativeHandleTable.cs` | Maps the opaque ids callers see onto `NativeHandle` (a workbook), `NativeRowCursor` and typed-reader instances. An id resolves only as its own kind and is never reissued after it is closed, so a stale or mismatched handle stays invalid permanently. | +| `Reading/NativeRowCursor.cs` | One row enumerator and its buffers, behind `xl_rows_open`. Each cursor is independent, which is what lets several threads read one workbook. | | `Reading/RowBlob.cs` | Row serialization. | -| `Reading/RowViewBuffer.cs` | Handle-owned native row behind `xl_next_row_view`. | +| `Reading/RowViewBuffer.cs` | Cursor-owned native row behind `xl_rows_next_view`. | | `include/excelreader.h` | Hand-written C header; keep in sync with `Exports*.cs`. | -Reading exports: `xl_open_file`, `xl_open_file_ex`, `xl_open_memory`, `xl_open_memory_ex`, `xl_close`, -`xl_sheet_count`, `xl_sheet_name`, `xl_sheet_name_at`, `xl_move_to_sheet`, `xl_is_date1904`, -`xl_next_row`, `xl_next_row_view`, `xl_read_all_blob`, `xl_read_all_decoded`, `xl_free_rows`, -`xl_parse_typed`, `xl_parse_typed_ex`, `xl_free_table`, `xl_infer_schema`, `xl_infer_schema_ex`, `xl_free_schema`, -`xl_last_error`, `xl_last_error_ptr`, `xl_parse_arrow`, `xl_parse_arrow_ex`. +Reading exports: `xl_open_file`, `xl_open_memory`, `xl_close`, `xl_sheet_count`, `xl_sheet_name_at`, +`xl_sheet_visibility_at`, `xl_sheet_index`, `xl_is_date1904`, `xl_rows_open`, `xl_rows_next`, +`xl_rows_next_view`, `xl_rows_read_all_blob`, `xl_rows_read_all_decoded`, `xl_rows_close`, +`xl_free_rows`, `xl_parse_typed`, `xl_free_table`, `xl_typed_reader_open`, `xl_typed_reader_next`, +`xl_typed_reader_close`, `xl_infer_schema`, `xl_free_schema`, `xl_last_error`, `xl_last_error_ptr`, +`xl_parse_arrow`, `xl_parse_arrow_stream`. + +A workbook handle has no current sheet and no cursor. Every read names its sheet by zero-based index +and opens its own enumerator, so calls on one handle may run on several threads at once. A cursor, +typed reader or Arrow stream is used by one thread at a time and keeps working after `xl_close` on +its workbook. Writing has two layers. The one-shot export, `xl_write_typed` (plus its in-memory twin `xl_write_typed_to_memory`), takes a whole `xl_table` and writes it in a single call; it takes an @@ -105,10 +115,10 @@ new integration at it before assuming your own linking approach is correct. ## Encrypted workbooks -`xl_open_options` grew a `password`/`password_len` pair (UTF-8 bytes, not NUL-terminated) to open a -password-protected OOXML workbook (.xlsx/.xlsb/.xlsm) through `xl_open_file_ex`/`xl_open_memory_ex`. +`xl_open_options` has a `password`/`password_len` pair (UTF-8 bytes, not NUL-terminated) to open a +password-protected OOXML workbook (.xlsx/.xlsb/.xlsm) through `xl_open_file` or `xl_open_memory`. Omitting it for an encrypted file returns `XL_STATUS_PASSWORD_REQUIRED`; a wrong password returns -`XL_STATUS_PASSWORD_INCORRECT`. Both are new status codes, and `xl_last_error`/`xl_last_error_ptr` +`XL_STATUS_PASSWORD_INCORRECT`. Both are error codes, and `xl_last_error`/`xl_last_error_ptr` carry the human-readable detail either way: ```c @@ -118,7 +128,7 @@ options.password = (const uint8_t*)"hunter2"; options.password_len = 7; xl_workbook* handle; -int32_t status = xl_open_file_ex(path, path_len, XL_FORMAT_AUTO, &options, &handle); +int32_t status = xl_open_file(path, path_len, XL_FORMAT_AUTO, &options, &handle); if (status == XL_STATUS_PASSWORD_REQUIRED || status == XL_STATUS_PASSWORD_INCORRECT) { /* prompt again */ } From 032c59537757c7ae71eb1b8d6bb58db8fd7e4857 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:27:16 -0300 Subject: [PATCH 27/49] fix(native): settle the last-error contract and bring the C smoke test to v6 A reading call now clears the thread's error first, so a non-OK status never shows an earlier call's message; xl_sheet_name_at reports a message for a bad index like every other function. The C smoke test binds the v6 exports. The parallel tests assert per-sheet content, not only row counts. Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Native/Exports.Arrow.cs | 4 + src/ExcelReader.Native/Exports.Reading.cs | 38 ++ src/ExcelReader.Native/Exports.Typed.cs | 6 + src/ExcelReader.Native/README.md | 6 +- .../Reading/NativeRowCursor.cs | 10 +- .../Reading/ReadApi.Open.cs | 2 + .../Reading/ReadApi.Rows.All.cs | 3 +- .../Reading/ReadApi.Sheets.cs | 17 +- .../Reading/RowViewBuffer.cs | 4 +- src/ExcelReader.Native/include/excelreader.h | 13 +- tests/ExcelReader.NativePgoTrainer/Program.cs | 2 +- tests/ExcelReader.NativeSmoke/smoke.c | 355 +++++++++++------- .../Native/ExportsOutParamTests.cs | 13 + .../Native/NativeApiConcurrencyTests.cs | 21 +- .../Native/NativeApiTests.InferSchemaText.cs | 25 -- .../Native/NativeApiTests.Reading.cs | 22 +- .../Native/NativeApiTests.Sheets.cs | 12 + .../Native/NativeApiTests.cs | 2 +- 18 files changed, 362 insertions(+), 193 deletions(-) diff --git a/src/ExcelReader.Native/Exports.Arrow.cs b/src/ExcelReader.Native/Exports.Arrow.cs index 222e8c00..bc69f61d 100644 --- a/src/ExcelReader.Native/Exports.Arrow.cs +++ b/src/ExcelReader.Native/Exports.Arrow.cs @@ -10,6 +10,8 @@ internal static unsafe partial class Exports public static int ParseArrow(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, ArrowArray* outArray, ArrowSchema* outSchema) { + NativeApi.ClearLastError(); + if (specs is null || outArray is null || outSchema is null || !TypedApi.IsValidSpecCount(specCount)) { return NativeStatus.InvalidArgument; @@ -42,6 +44,8 @@ public static int ParseArrow(nint handle, int sheet, NativeColumnSpecRaw* specs, public static int ParseArrowStream(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, long maxRows, ArrowArrayStream* outStream) { + NativeApi.ClearLastError(); + if (outStream is null) { return NativeStatus.InvalidArgument; diff --git a/src/ExcelReader.Native/Exports.Reading.cs b/src/ExcelReader.Native/Exports.Reading.cs index 2011274a..4bb34f11 100644 --- a/src/ExcelReader.Native/Exports.Reading.cs +++ b/src/ExcelReader.Native/Exports.Reading.cs @@ -8,6 +8,12 @@ internal static unsafe partial class Exports [UnmanagedCallersOnly(EntryPoint = "xl_open_file")] public static int OpenFile(byte* path, int pathLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) { + NativeApi.ClearLastError(); + if (outHandle is not null) + { + *outHandle = 0; + } + if (!IsValidOpenRequest(path, pathLength, outHandle)) { return NativeStatus.InvalidArgument; @@ -24,6 +30,12 @@ public static int OpenFile(byte* path, int pathLength, int format, NativeOpenOpt [UnmanagedCallersOnly(EntryPoint = "xl_open_memory")] public static int OpenMemory(byte* data, int dataLength, int format, NativeOpenOptionsRaw* options, nint* outHandle) { + NativeApi.ClearLastError(); + if (outHandle is not null) + { + *outHandle = 0; + } + if (!IsValidOpenRequest(data, dataLength, outHandle)) { return NativeStatus.InvalidArgument; @@ -40,6 +52,8 @@ public static int OpenMemory(byte* data, int dataLength, int format, NativeOpenO [UnmanagedCallersOnly(EntryPoint = "xl_close")] public static int Close(nint handle) { + NativeApi.ClearLastError(); + if (handle == 0) { return NativeStatus.InvalidHandle; @@ -56,6 +70,8 @@ public static int Close(nint handle) [UnmanagedCallersOnly(EntryPoint = "xl_sheet_count")] public static int SheetCount(nint handle, int* outCount) { + NativeApi.ClearLastError(); + if (outCount is null) { return NativeStatus.InvalidArgument; @@ -69,6 +85,8 @@ public static int SheetCount(nint handle, int* outCount) [UnmanagedCallersOnly(EntryPoint = "xl_sheet_name_at")] public static int SheetNameAt(nint handle, int index, byte* buffer, int capacity, int* outLength) { + NativeApi.ClearLastError(); + if (!IsValidOutBuffer(buffer, capacity, outLength)) { return NativeStatus.InvalidArgument; @@ -82,6 +100,8 @@ public static int SheetNameAt(nint handle, int index, byte* buffer, int capacity [UnmanagedCallersOnly(EntryPoint = "xl_is_date1904")] public static int IsDate1904(nint handle, int* outFlag) { + NativeApi.ClearLastError(); + if (outFlag is null) { return NativeStatus.InvalidArgument; @@ -95,6 +115,8 @@ public static int IsDate1904(nint handle, int* outFlag) [UnmanagedCallersOnly(EntryPoint = "xl_sheet_visibility_at")] public static int SheetVisibilityAt(nint handle, int index, int* outVisibility) { + NativeApi.ClearLastError(); + if (outVisibility is null) { return NativeStatus.InvalidArgument; @@ -108,6 +130,8 @@ public static int SheetVisibilityAt(nint handle, int index, int* outVisibility) [UnmanagedCallersOnly(EntryPoint = "xl_sheet_index")] public static int SheetIndex(nint handle, byte* name, int nameLength, int* outIndex) { + NativeApi.ClearLastError(); + if (outIndex is null) { return NativeStatus.InvalidArgument; @@ -126,6 +150,8 @@ public static int SheetIndex(nint handle, byte* name, int nameLength, int* outIn [UnmanagedCallersOnly(EntryPoint = "xl_rows_open")] public static int RowsOpen(nint handle, int sheet, nint* outRows) { + NativeApi.ClearLastError(); + if (outRows is null) { return NativeStatus.InvalidArgument; @@ -143,6 +169,8 @@ public static int RowsOpen(nint handle, int sheet, nint* outRows) [UnmanagedCallersOnly(EntryPoint = "xl_rows_close")] public static int RowsClose(nint rows) { + NativeApi.ClearLastError(); + if (!NativeHandleTable.TryUnregister(rows, out NativeRowCursor? cursor)) { return NativeStatus.InvalidHandle; @@ -154,6 +182,8 @@ public static int RowsClose(nint rows) [UnmanagedCallersOnly(EntryPoint = "xl_rows_next")] public static int RowsNext(nint rows, byte* buffer, int capacity, int* outWritten) { + NativeApi.ClearLastError(); + if (!IsValidOutBuffer(buffer, capacity, outWritten)) { return NativeStatus.InvalidArgument; @@ -167,6 +197,8 @@ public static int RowsNext(nint rows, byte* buffer, int capacity, int* outWritte [UnmanagedCallersOnly(EntryPoint = "xl_rows_next_view")] public static int RowsNextView(nint rows, NativeRow* outRow) { + NativeApi.ClearLastError(); + if (outRow is null) { return NativeStatus.InvalidArgument; @@ -180,6 +212,8 @@ public static int RowsNextView(nint rows, NativeRow* outRow) [UnmanagedCallersOnly(EntryPoint = "xl_rows_read_all_blob")] public static int RowsReadAllBlob(nint rows, byte* buffer, int capacity, int* outWritten) { + NativeApi.ClearLastError(); + if (!IsValidOutBuffer(buffer, capacity, outWritten)) { return NativeStatus.InvalidArgument; @@ -193,6 +227,8 @@ public static int RowsReadAllBlob(nint rows, byte* buffer, int capacity, int* ou [UnmanagedCallersOnly(EntryPoint = "xl_rows_read_all_decoded")] public static int RowsReadAllDecoded(nint rows, NativeRows* outRows) { + NativeApi.ClearLastError(); + if (outRows is null) { return NativeStatus.InvalidArgument; @@ -223,6 +259,8 @@ public static void FreeRows(NativeRows* rows) [UnmanagedCallersOnly(EntryPoint = "xl_infer_schema")] public static int InferSchema(nint handle, int sheet, int headerRow, int sampleSize, int flags, NativeInferredSchema* outSchema) { + NativeApi.ClearLastError(); + if (outSchema is null) { return NativeStatus.InvalidArgument; diff --git a/src/ExcelReader.Native/Exports.Typed.cs b/src/ExcelReader.Native/Exports.Typed.cs index 7901e5fa..dfb03947 100644 --- a/src/ExcelReader.Native/Exports.Typed.cs +++ b/src/ExcelReader.Native/Exports.Typed.cs @@ -9,6 +9,8 @@ internal static unsafe partial class Exports public static int ParseTyped(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, int degreeOfParallelism, NativeTable* outTable) { + NativeApi.ClearLastError(); + if (specs is null || outTable is null || !TypedApi.IsValidSpecCount(specCount)) { return NativeStatus.InvalidArgument; @@ -55,6 +57,8 @@ public static void FreeTable(NativeTable* table) public static int TypedReaderOpen(nint handle, int sheet, NativeColumnSpecRaw* specs, int specCount, int headerRow, long maxRows, nint* outReader) { + NativeApi.ClearLastError(); + if (outReader is null) { return NativeStatus.InvalidArgument; @@ -87,6 +91,8 @@ public static int TypedReaderOpen(nint handle, int sheet, NativeColumnSpecRaw* s [UnmanagedCallersOnly(EntryPoint = "xl_typed_reader_next")] public static int TypedReaderNext(nint reader, NativeTable* outTable) { + NativeApi.ClearLastError(); + if (outTable is null) { return NativeStatus.InvalidArgument; diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index c254b127..7050a302 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -29,15 +29,15 @@ look for `; No PGO data` in the listing header. `xl_rows_next_view` over the 65K benchmark fixtures in XLSX, XLSB and CSV. Record it and convert the trace (needs `dotnet-trace` and `dotnet-pgo`): -The checked-in profile predates the v6 row cursor: `ReadApi.NextRow` and `ReadApi.NextRowView` changed -signature, so they compile without profile data until the profile is regenerated and re-measured. - dotnet build tests/ExcelReader.NativePgoTrainer -c Release DOTNET_TieredPGO=1 DOTNET_ReadyToRun=0 DOTNET_TC_QuickJitForLoops=1 \ dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x1F000080018:5 -o pgo.nettrace \ -- "$PWD/tests/ExcelReader.NativePgoTrainer/bin/Release/net10.0/ExcelReader.NativePgoTrainer" dotnet-pgo create-mibc -t pgo.nettrace --exclude-methods 'Xlsb|Biff12' -o src/ExcelReader.Native/pgo/excelreader.mibc +The checked-in profile predates the v6 row cursor: `ReadApi.NextRow` and `ReadApi.NextRowView` changed +signature, so they compile without profile data until the profile is regenerated and re-measured. + The XLSB methods are excluded on purpose: with their profile the XLSB parse got 3% slower, and without it 1.12x faster. Re-measured with the regenerated profile: including them makes XLSB typed parsing ~16% slower. Measure every format before and after replacing the profile. Some formats diff --git a/src/ExcelReader.Native/Reading/NativeRowCursor.cs b/src/ExcelReader.Native/Reading/NativeRowCursor.cs index 3dbfb01e..1684606f 100644 --- a/src/ExcelReader.Native/Reading/NativeRowCursor.cs +++ b/src/ExcelReader.Native/Reading/NativeRowCursor.cs @@ -30,8 +30,14 @@ internal NativeRowCursor(IExcelRowEnumerator rows) public void Dispose() { - Rows.Dispose(); - View?.Dispose(); + try + { + Rows.Dispose(); + } + finally + { + View?.Dispose(); + } } } } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Open.cs b/src/ExcelReader.Native/Reading/ReadApi.Open.cs index c01336f6..7aa12257 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Open.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Open.cs @@ -10,6 +10,7 @@ internal static int OpenFile(ReadOnlySpan utf8Path, int format, out Native handle = null; if (!IsKnownFormat(format)) { + NativeApi.SetLastError($"format must be one of the XL_FORMAT_* values; got {format}."); return NativeStatus.InvalidArgument; } @@ -37,6 +38,7 @@ internal static int OpenMemory(ReadOnlySpan data, int format, out NativeHa handle = null; if (!IsKnownFormat(format)) { + NativeApi.SetLastError($"format must be one of the XL_FORMAT_* values; got {format}."); return NativeStatus.InvalidArgument; } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs index 3ee39a8b..b2f567d5 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Rows.All.cs @@ -31,6 +31,7 @@ internal static int ReadAllBlob(NativeRowCursor? cursor, Span buffer, out BinaryPrimitives.WriteInt32LittleEndian(buffer, cursor.AllRowsCount); cursor.AllRowsScratch?.CopyTo(buffer[sizeof(int)..cursor.AllRowsLength]); cursor.AllRowsPending = false; + cursor.AllRowsScratch = null; return NativeStatus.Ok; } catch (Exception exception) @@ -77,7 +78,7 @@ private static void AppendRow(ChunkedBuffer output, ReadOnlySpan row if (required > int.MaxValue) { throw new InvalidOperationException( - "xl_read_all_blob's accumulated result exceeds the 2 GiB int32 limit of this API; " + + "xl_rows_read_all_blob's accumulated result exceeds the 2 GiB int32 limit of this API; " + "use xl_parse_typed instead, which is columnar, uses int64_t lengths, and is markedly faster."); } diff --git a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs index 9b7f6c72..c60af9cb 100644 --- a/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs +++ b/src/ExcelReader.Native/Reading/ReadApi.Sheets.cs @@ -54,21 +54,14 @@ internal static int SheetNameAt(NativeHandle? handle, int index, Span buff { return NativeStatus.InvalidHandle; } - if (index < 0) - { - return NativeStatus.InvalidArgument; - } - NativeApi.ClearLastError(); - try - { - return CopyUtf8(handle.Workbook.SheetAt(index).Name, buffer, out length); - } - catch (Exception exception) + int status = ResolveSheet(handle, index, out IExcelSheet? sheet); + if (status != NativeStatus.Ok) { - NativeApi.SetLastError(exception.Message); - return NativeStatus.Error; + return status; } + + return CopyUtf8(sheet!.Name, buffer, out length); } private static int CopyUtf8(string value, Span buffer, out int length) diff --git a/src/ExcelReader.Native/Reading/RowViewBuffer.cs b/src/ExcelReader.Native/Reading/RowViewBuffer.cs index 96e50686..8431a902 100644 --- a/src/ExcelReader.Native/Reading/RowViewBuffer.cs +++ b/src/ExcelReader.Native/Reading/RowViewBuffer.cs @@ -6,7 +6,7 @@ namespace ExcelReader.Native.Reading { /// - /// The row xl_next_row_view hands out: cell structs and NUL-terminated values in native memory + /// The row xl_rows_next_view hands out: cell structs and NUL-terminated values in native memory /// owned by the handle, overwritten by the next row and freed with it. /// internal sealed unsafe class RowViewBuffer : IDisposable @@ -36,7 +36,7 @@ internal NativeRow Fill(in Row row) return Finish(count); } - /// Takes over a row xl_next_row already serialized but could not hand back. + /// Takes over a row xl_rows_next already serialized but could not hand back. internal NativeRow Fill(ReadOnlySpan blob) { int cellCount = BinaryPrimitives.ReadInt32LittleEndian(blob); diff --git a/src/ExcelReader.Native/include/excelreader.h b/src/ExcelReader.Native/include/excelreader.h index 87381caf..2c857eee 100644 --- a/src/ExcelReader.Native/include/excelreader.h +++ b/src/ExcelReader.Native/include/excelreader.h @@ -116,15 +116,20 @@ int32_t xl_is_date1904(xl_workbook* handle, int32_t* out_flag); working after xl_close on its workbook. Release it with xl_rows_close. */ int32_t xl_rows_open(xl_workbook* handle, int32_t sheet, xl_rows** out_rows); +/* A NULL or unknown cursor is XL_INVALID_HANDLE and nothing is released. */ int32_t xl_rows_close(xl_rows* rows); +/* XL_BUFFER_TOO_SMALL writes the needed size to out_written; call xl_rows_next again with a larger + buffer before any other read on that cursor, and the same row is returned. */ int32_t xl_rows_next(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); /* out_row points into memory the cursor owns: overwritten by the next xl_rows_next_view on that cursor, released by xl_rows_close. Values are NUL-terminated. Do not pass it to xl_free_rows. */ int32_t xl_rows_next_view(xl_rows* rows, xl_row* out_row); -/* Reads the rows the cursor has not yet returned. */ +/* Reads the rows the cursor has not yet returned. XL_BUFFER_TOO_SMALL writes the needed size to + out_written; call xl_rows_read_all_blob again with a larger buffer before any other read on that + cursor. */ int32_t xl_rows_read_all_blob(xl_rows* rows, uint8_t* buffer, int32_t capacity, int32_t* out_written); typedef struct xl_rows_decoded { @@ -186,6 +191,7 @@ int32_t xl_typed_reader_open(xl_workbook* handle, int32_t sheet, const xl_column int32_t xl_typed_reader_next(xl_typed_reader* reader, xl_table* out_table); +/* A NULL or unknown reader is ignored. */ void xl_typed_reader_close(xl_typed_reader* reader); @@ -308,6 +314,11 @@ int32_t xl_csv_aggregate_memory(const uint8_t* data, int32_t data_len, const xl_csv_parallel_options* options, void** out_state); +/* The message for the calling thread's most recent failure. After any non-OK status from a + function that opens, reads or inspects a workbook (xl_open_*, xl_close, xl_sheet_*, xl_is_date1904, + xl_rows_*, xl_parse_*, xl_typed_reader_*, xl_infer_schema), it is that call's message, or empty + when the status alone says it all (a NULL out-pointer, an unknown handle): never a message left by + an earlier call. The writer, CSV-aggregation and encryption functions set it on XL_ERROR. */ int32_t xl_last_error(uint8_t* buffer, int32_t capacity, int32_t* out_len); const uint8_t* xl_last_error_ptr(int32_t* out_len); diff --git a/tests/ExcelReader.NativePgoTrainer/Program.cs b/tests/ExcelReader.NativePgoTrainer/Program.cs index cf7be64b..3a24806c 100644 --- a/tests/ExcelReader.NativePgoTrainer/Program.cs +++ b/tests/ExcelReader.NativePgoTrainer/Program.cs @@ -5,7 +5,7 @@ namespace ExcelReader.NativePgoTrainer { /// - /// Runs the managed implementations of xl_parse_typed, xl_next_row and xl_next_row_view over the 65K + /// Runs the managed implementations of xl_parse_typed, xl_rows_next and xl_rows_next_view over the 65K /// benchmark fixtures, so a dotnet-trace of this process yields the static PGO profile NativeAOT compiles /// ExcelReader.Native with. See "Static PGO profile" in src/ExcelReader.Native/README.md. /// diff --git a/tests/ExcelReader.NativeSmoke/smoke.c b/tests/ExcelReader.NativeSmoke/smoke.c index be70c44f..0720f16d 100644 --- a/tests/ExcelReader.NativeSmoke/smoke.c +++ b/tests/ExcelReader.NativeSmoke/smoke.c @@ -33,9 +33,9 @@ XL_STATIC_ASSERT(offsetof(xl_row, cell_count) == 0, row_cell_count); XL_STATIC_ASSERT(offsetof(xl_row, cells) == 8, row_cells); XL_STATIC_ASSERT(sizeof(xl_row) == 16, row_size); -XL_STATIC_ASSERT(offsetof(xl_rows, row_count) == 0, rows_row_count); -XL_STATIC_ASSERT(offsetof(xl_rows, rows) == 8, rows_rows); -XL_STATIC_ASSERT(sizeof(xl_rows) == 16, rows_size); +XL_STATIC_ASSERT(offsetof(xl_rows_decoded, row_count) == 0, rows_decoded_row_count); +XL_STATIC_ASSERT(offsetof(xl_rows_decoded, rows) == 8, rows_decoded_rows); +XL_STATIC_ASSERT(sizeof(xl_rows_decoded) == 16, rows_decoded_size); XL_STATIC_ASSERT(offsetof(xl_column_spec, names) == 0, column_spec_names); XL_STATIC_ASSERT(offsetof(xl_column_spec, name_lens) == 8, column_spec_name_lens); @@ -119,26 +119,27 @@ static void* load_symbol(xl_lib_handle lib, const char* name) } typedef int32_t (*xl_abi_version_fn)(void); -typedef int32_t (*xl_open_file_fn)(const uint8_t*, int32_t, int32_t, xl_workbook**); -typedef int32_t (*xl_open_file_ex_fn)(const uint8_t*, int32_t, int32_t, const xl_open_options*, xl_workbook**); +typedef int32_t (*xl_open_file_fn)(const uint8_t*, int32_t, int32_t, const xl_open_options*, xl_workbook**); typedef int32_t (*xl_close_fn)(xl_workbook*); typedef int32_t (*xl_sheet_count_fn)(xl_workbook*, int32_t*); -typedef int32_t (*xl_sheet_name_fn)(xl_workbook*, uint8_t*, int32_t, int32_t*); typedef int32_t (*xl_sheet_name_at_fn)(xl_workbook*, int32_t, uint8_t*, int32_t, int32_t*); +typedef int32_t (*xl_sheet_visibility_at_fn)(xl_workbook*, int32_t, int32_t*); +typedef int32_t (*xl_sheet_index_fn)(xl_workbook*, const uint8_t*, int32_t, int32_t*); typedef int32_t (*xl_is_date1904_fn)(xl_workbook*, int32_t*); -typedef int32_t (*xl_next_row_fn)(xl_workbook*, uint8_t*, int32_t, int32_t*); -typedef int32_t (*xl_next_row_view_fn)(xl_workbook*, xl_row*); -typedef int32_t (*xl_read_all_blob_fn)(xl_workbook*, uint8_t*, int32_t, int32_t*); -typedef int32_t (*xl_read_all_decoded_fn)(xl_workbook*, xl_rows*); -typedef void (*xl_free_rows_fn)(xl_rows*); -typedef int32_t (*xl_parse_typed_fn)(xl_workbook*, const xl_column_spec*, int32_t, int32_t, xl_table*); -typedef int32_t (*xl_parse_typed_ex_fn)(xl_workbook*, const xl_column_spec*, int32_t, int32_t, int32_t, xl_table*); +typedef int32_t (*xl_rows_open_fn)(xl_workbook*, int32_t, xl_rows**); +typedef int32_t (*xl_rows_close_fn)(xl_rows*); +typedef int32_t (*xl_rows_next_fn)(xl_rows*, uint8_t*, int32_t, int32_t*); +typedef int32_t (*xl_rows_next_view_fn)(xl_rows*, xl_row*); +typedef int32_t (*xl_rows_read_all_blob_fn)(xl_rows*, uint8_t*, int32_t, int32_t*); +typedef int32_t (*xl_rows_read_all_decoded_fn)(xl_rows*, xl_rows_decoded*); +typedef void (*xl_free_rows_fn)(xl_rows_decoded*); +typedef int32_t (*xl_parse_typed_fn)(xl_workbook*, int32_t, const xl_column_spec*, int32_t, int32_t, int32_t, xl_table*); typedef void (*xl_free_table_fn)(xl_table*); -typedef int32_t (*xl_infer_schema_fn)(xl_workbook*, int32_t, int32_t, xl_inferred_schema*); -typedef int32_t (*xl_infer_schema_ex_fn)(xl_workbook*, int32_t, int32_t, int32_t, xl_inferred_schema*); +typedef int32_t (*xl_infer_schema_fn)(xl_workbook*, int32_t, int32_t, int32_t, int32_t, xl_inferred_schema*); typedef void (*xl_free_schema_fn)(xl_inferred_schema*); typedef const uint8_t* (*xl_last_error_ptr_fn)(int32_t*); -typedef int32_t (*xl_parse_arrow_fn)(xl_workbook*, const xl_column_spec*, int32_t, int32_t, struct ArrowArray*, struct ArrowSchema*); +typedef int32_t (*xl_parse_arrow_fn)(xl_workbook*, int32_t, const xl_column_spec*, int32_t, int32_t, int32_t, + struct ArrowArray*, struct ArrowSchema*); typedef int32_t (*xl_write_typed_fn)(const uint8_t*, int32_t, int32_t, const xl_column_spec*, const xl_table*, const xl_write_options*); typedef int32_t (*xl_csv_aggregate_file_fn)(const uint8_t*, int32_t, const xl_csv_aggregation*, @@ -150,22 +151,22 @@ typedef struct { xl_abi_version_fn abi_version; xl_open_file_fn open_file; - xl_open_file_ex_fn open_file_ex; xl_close_fn close_; xl_sheet_count_fn sheet_count; - xl_sheet_name_fn sheet_name; xl_sheet_name_at_fn sheet_name_at; + xl_sheet_visibility_at_fn sheet_visibility_at; + xl_sheet_index_fn sheet_index; xl_is_date1904_fn is_date1904; - xl_next_row_fn next_row; - xl_next_row_view_fn next_row_view; - xl_read_all_blob_fn read_all_blob; - xl_read_all_decoded_fn read_all_decoded; + xl_rows_open_fn rows_open; + xl_rows_close_fn rows_close; + xl_rows_next_fn rows_next; + xl_rows_next_view_fn rows_next_view; + xl_rows_read_all_blob_fn rows_read_all_blob; + xl_rows_read_all_decoded_fn rows_read_all_decoded; xl_free_rows_fn free_rows; xl_parse_typed_fn parse_typed; - xl_parse_typed_ex_fn parse_typed_ex; xl_free_table_fn free_table; xl_infer_schema_fn infer_schema; - xl_infer_schema_ex_fn infer_schema_ex; xl_free_schema_fn free_schema; xl_last_error_ptr_fn last_error_ptr; xl_parse_arrow_fn parse_arrow; @@ -187,22 +188,22 @@ static int bind_all(xl_lib_handle lib, api_t* api) { BIND(abi_version, xl_abi_version_fn, "xl_abi_version"); BIND(open_file, xl_open_file_fn, "xl_open_file"); - BIND(open_file_ex, xl_open_file_ex_fn, "xl_open_file_ex"); BIND(close_, xl_close_fn, "xl_close"); BIND(sheet_count, xl_sheet_count_fn, "xl_sheet_count"); - BIND(sheet_name, xl_sheet_name_fn, "xl_sheet_name"); BIND(sheet_name_at, xl_sheet_name_at_fn, "xl_sheet_name_at"); + BIND(sheet_visibility_at, xl_sheet_visibility_at_fn, "xl_sheet_visibility_at"); + BIND(sheet_index, xl_sheet_index_fn, "xl_sheet_index"); BIND(is_date1904, xl_is_date1904_fn, "xl_is_date1904"); - BIND(next_row, xl_next_row_fn, "xl_next_row"); - BIND(next_row_view, xl_next_row_view_fn, "xl_next_row_view"); - BIND(read_all_blob, xl_read_all_blob_fn, "xl_read_all_blob"); - BIND(read_all_decoded, xl_read_all_decoded_fn, "xl_read_all_decoded"); + BIND(rows_open, xl_rows_open_fn, "xl_rows_open"); + BIND(rows_close, xl_rows_close_fn, "xl_rows_close"); + BIND(rows_next, xl_rows_next_fn, "xl_rows_next"); + BIND(rows_next_view, xl_rows_next_view_fn, "xl_rows_next_view"); + BIND(rows_read_all_blob, xl_rows_read_all_blob_fn, "xl_rows_read_all_blob"); + BIND(rows_read_all_decoded, xl_rows_read_all_decoded_fn, "xl_rows_read_all_decoded"); BIND(free_rows, xl_free_rows_fn, "xl_free_rows"); BIND(parse_typed, xl_parse_typed_fn, "xl_parse_typed"); - BIND(parse_typed_ex, xl_parse_typed_ex_fn, "xl_parse_typed_ex"); BIND(free_table, xl_free_table_fn, "xl_free_table"); BIND(infer_schema, xl_infer_schema_fn, "xl_infer_schema"); - BIND(infer_schema_ex, xl_infer_schema_ex_fn, "xl_infer_schema_ex"); BIND(free_schema, xl_free_schema_fn, "xl_free_schema"); BIND(last_error_ptr, xl_last_error_ptr_fn, "xl_last_error_ptr"); BIND(parse_arrow, xl_parse_arrow_fn, "xl_parse_arrow"); @@ -233,7 +234,7 @@ static void set_spec_name1(xl_column_spec* spec, const uint8_t** name_slot, int3 static int32_t open_fixture(const api_t* api, const char* fixture, xl_workbook** out_handle) { size_t path_len = strlen(fixture); - return api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, out_handle); + return api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, NULL, out_handle); } static int test_abi_version(const api_t* api) @@ -246,7 +247,7 @@ static int test_open_missing_file_reports_an_error(const api_t* api) { xl_workbook* handle = NULL; const char* missing = "this-file-does-not-exist.xlsb"; - int32_t status = api->open_file((const uint8_t*)missing, (int32_t)strlen(missing), XL_FORMAT_XLSB, &handle); + int32_t status = api->open_file((const uint8_t*)missing, (int32_t)strlen(missing), XL_FORMAT_XLSB, NULL, &handle); CHECK(status != XL_OK, "opening a missing file must not report XL_OK"); CHECK(handle == NULL, "a failed open must not hand back a handle"); @@ -267,17 +268,37 @@ static int test_sheets_and_flags(const api_t* api, const char* fixture) uint8_t name_buffer[64]; int32_t name_len = 0; - CHECK(api->sheet_name(handle, name_buffer, 0, &name_len) == XL_BUFFER_TOO_SMALL, - "xl_sheet_name with capacity 0 must report XL_BUFFER_TOO_SMALL"); - CHECK(name_len > 0, "xl_sheet_name must report the required length on XL_BUFFER_TOO_SMALL"); + CHECK(api->sheet_name_at(handle, 0, name_buffer, 0, &name_len) == XL_BUFFER_TOO_SMALL, + "xl_sheet_name_at with capacity 0 must report XL_BUFFER_TOO_SMALL"); + CHECK(name_len > 0, "xl_sheet_name_at must report the required length on XL_BUFFER_TOO_SMALL"); CHECK((size_t)name_len < sizeof(name_buffer), "test fixture's sheet name must fit the local buffer"); - CHECK(api->sheet_name(handle, name_buffer, (int32_t)sizeof(name_buffer), &name_len) == XL_OK, - "xl_sheet_name must succeed once the buffer is big enough"); - CHECK(name_len == 9 && memcmp(name_buffer, "Planilha1", 9) == 0, "sheet name must be Planilha1"); - CHECK(api->sheet_name_at(handle, 0, name_buffer, (int32_t)sizeof(name_buffer), &name_len) == XL_OK, - "xl_sheet_name_at(0) must succeed"); - CHECK(name_len == 9 && memcmp(name_buffer, "Planilha1", 9) == 0, "xl_sheet_name_at(0) must match xl_sheet_name"); + "xl_sheet_name_at(0) must succeed once the buffer is big enough"); + CHECK(name_len == 9 && memcmp(name_buffer, "Planilha1", 9) == 0, "sheet name must be Planilha1"); + CHECK(api->sheet_name_at(handle, -1, name_buffer, (int32_t)sizeof(name_buffer), &name_len) == XL_INVALID_ARGUMENT, + "a negative sheet index must be XL_INVALID_ARGUMENT"); + CHECK(api->sheet_name_at(handle, sheet_count, name_buffer, (int32_t)sizeof(name_buffer), &name_len) == XL_ERROR, + "a sheet index at xl_sheet_count must be XL_ERROR"); + + int32_t visibility = -1; + CHECK(api->sheet_visibility_at(handle, 0, &visibility) == XL_OK, "xl_sheet_visibility_at(0) must succeed"); + CHECK(visibility == XL_SHEET_VISIBLE, "RealExcel.xlsb's only sheet is visible"); + CHECK(api->sheet_visibility_at(handle, sheet_count, &visibility) == XL_ERROR, + "xl_sheet_visibility_at past the last sheet must be XL_ERROR"); + CHECK(api->sheet_visibility_at(handle, -1, &visibility) == XL_INVALID_ARGUMENT, + "xl_sheet_visibility_at with a negative index must be XL_INVALID_ARGUMENT"); + + int32_t index = -2; + CHECK(api->sheet_index(handle, (const uint8_t*)"Planilha1", 9, &index) == XL_OK && index == 0, + "xl_sheet_index must find Planilha1 at 0"); + index = -2; + CHECK(api->sheet_index(handle, (const uint8_t*)"pLANILHA1", 9, &index) == XL_OK && index == 0, + "xl_sheet_index must ignore case"); + index = -2; + CHECK(api->sheet_index(handle, (const uint8_t*)"Missing", 7, &index) == XL_OK && index == -1, + "xl_sheet_index must return XL_OK with -1 for a name that matches no sheet"); + CHECK(api->sheet_index(handle, (const uint8_t*)"Planilha1", 9, NULL) == XL_INVALID_ARGUMENT, + "xl_sheet_index must reject a NULL out_index"); int32_t date1904 = -1; CHECK(api->is_date1904(handle, &date1904) == XL_OK, "xl_is_date1904 must succeed"); @@ -287,22 +308,25 @@ static int test_sheets_and_flags(const api_t* api, const char* fixture) return 0; } -static int test_next_row_blob_and_growth(const api_t* api, const char* fixture) +static int test_rows_next_blob_and_growth(const api_t* api, const char* fixture) { xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); + xl_rows* rows = NULL; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + uint8_t tiny[1]; int32_t written = 0; - int32_t status = api->next_row(handle, tiny, 0, &written); - CHECK(status == XL_BUFFER_TOO_SMALL, "xl_next_row with capacity 0 must report XL_BUFFER_TOO_SMALL"); - CHECK(written > 0, "xl_next_row must report the required size on XL_BUFFER_TOO_SMALL"); + int32_t status = api->rows_next(rows, tiny, 0, &written); + CHECK(status == XL_BUFFER_TOO_SMALL, "xl_rows_next with capacity 0 must report XL_BUFFER_TOO_SMALL"); + CHECK(written > 0, "xl_rows_next must report the required size on XL_BUFFER_TOO_SMALL"); uint8_t* buffer = (uint8_t*)malloc((size_t)written); CHECK(buffer != NULL, "test allocation must succeed"); int32_t capacity = written; - status = api->next_row(handle, buffer, capacity, &written); - CHECK(status == XL_OK, "retrying xl_next_row with the reported size must succeed - no row is lost"); + status = api->rows_next(rows, buffer, capacity, &written); + CHECK(status == XL_OK, "retrying xl_rows_next with the reported size must succeed - no row is lost"); int32_t cell_count = 0; memcpy(&cell_count, buffer, sizeof(int32_t)); @@ -321,47 +345,52 @@ static int test_next_row_blob_and_growth(const api_t* api, const char* fixture) uint8_t scratch[4096]; for (;;) { - status = api->next_row(handle, scratch, (int32_t)sizeof(scratch), &written); + status = api->rows_next(rows, scratch, (int32_t)sizeof(scratch), &written); if (status == XL_EOF) { break; } - CHECK(status == XL_OK, "xl_next_row must succeed for every remaining row of this fixture"); + CHECK(status == XL_OK, "xl_rows_next must succeed for every remaining row of this fixture"); row_count++; } CHECK(row_count == 101, "RealExcel.xlsb has 101 rows (1 header + 100 data)"); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } -static int test_next_row_view(const api_t* api, const char* fixture) +static int test_rows_next_view(const api_t* api, const char* fixture) { xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); - CHECK(api->next_row_view(handle, NULL) == XL_INVALID_ARGUMENT, "xl_next_row_view must reject a NULL out_row"); + xl_rows* rows = NULL; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + + CHECK(api->rows_next_view(rows, NULL) == XL_INVALID_ARGUMENT, "xl_rows_next_view must reject a NULL out_row"); xl_row row = {0}; - CHECK(api->next_row_view(handle, &row) == XL_OK, "xl_next_row_view must return the header row"); + CHECK(api->rows_next_view(rows, &row) == XL_OK, "xl_rows_next_view must return the header row"); CHECK(row.cell_count == 18 && row.cells != NULL, "RealExcel.xlsb's header row has 18 columns"); CHECK(row.cells[0].column == 0 && row.cells[0].type == XL_CELL_STRING, "the first header cell is a string in column 0"); CHECK(row.cells[0].value_len == 7 && memcmp(row.cells[0].value, "Coluna1", 7) == 0, "first header cell must read Coluna1"); - CHECK(row.cells[0].value[7] == 0, "xl_next_row_view values are NUL-terminated"); + CHECK(row.cells[0].value[7] == 0, "xl_rows_next_view values are NUL-terminated"); int row_count = 1; for (;;) { - int32_t status = api->next_row_view(handle, &row); + int32_t status = api->rows_next_view(rows, &row); if (status == XL_EOF) { break; } - CHECK(status == XL_OK, "xl_next_row_view must succeed for every remaining row of this fixture"); + CHECK(status == XL_OK, "xl_rows_next_view must succeed for every remaining row of this fixture"); row_count++; } - CHECK(row_count == 101, "xl_next_row_view must see all 101 rows"); + CHECK(row_count == 101, "xl_rows_next_view must see all 101 rows"); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } @@ -371,61 +400,112 @@ static int test_read_all_blob_and_decoded(const api_t* api, const char* fixture) xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); + xl_rows* rows = NULL; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + static uint8_t buffer[1 << 20]; int32_t written = 0; - CHECK(api->read_all_blob(handle, buffer, (int32_t)sizeof(buffer), &written) == XL_OK, - "xl_read_all_blob must succeed with a generously sized buffer"); + CHECK(api->rows_read_all_blob(rows, buffer, (int32_t)sizeof(buffer), &written) == XL_OK, + "xl_rows_read_all_blob must succeed with a generously sized buffer"); int32_t row_count = 0; memcpy(&row_count, buffer, sizeof(int32_t)); - CHECK(row_count == 101, "xl_read_all_blob must report all 101 rows"); + CHECK(row_count == 101, "xl_rows_read_all_blob must report all 101 rows"); - CHECK(api->read_all_blob(handle, buffer, (int32_t)sizeof(buffer), &written) == XL_OK, - "a drained xl_read_all_blob call must still be XL_OK"); + CHECK(api->rows_read_all_blob(rows, buffer, (int32_t)sizeof(buffer), &written) == XL_OK, + "a drained xl_rows_read_all_blob call must still be XL_OK"); memcpy(&row_count, buffer, sizeof(int32_t)); - CHECK(row_count == 0, "a drained sheet's xl_read_all_blob must report zero rows"); + CHECK(row_count == 0, "a drained cursor's xl_rows_read_all_blob must report zero rows"); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); - xl_rows rows; - memset(&rows, 0, sizeof(rows)); - CHECK(api->read_all_decoded(handle, &rows) == XL_OK, "xl_read_all_decoded must succeed"); - CHECK(rows.row_count == 101, "xl_read_all_decoded must report all 101 rows"); - api->free_rows(&rows); - - xl_rows drained; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + xl_rows_decoded decoded; + memset(&decoded, 0, sizeof(decoded)); + CHECK(api->rows_read_all_decoded(rows, &decoded) == XL_OK, "xl_rows_read_all_decoded must succeed"); + CHECK(decoded.row_count == 101, "xl_rows_read_all_decoded must report all 101 rows"); + api->free_rows(&decoded); + + xl_rows_decoded drained; memset(&drained, 0, sizeof(drained)); - CHECK(api->read_all_decoded(handle, &drained) == XL_OK, "a drained xl_read_all_decoded call must still be XL_OK"); - CHECK(drained.row_count == 0, "a drained sheet's xl_read_all_decoded must report zero rows"); + CHECK(api->rows_read_all_decoded(rows, &drained) == XL_OK, "a drained xl_rows_read_all_decoded call must still be XL_OK"); + CHECK(drained.row_count == 0, "a drained cursor's xl_rows_read_all_decoded must report zero rows"); api->free_rows(&drained); - xl_rows zeroed; + xl_rows_decoded zeroed; memset(&zeroed, 0, sizeof(zeroed)); api->free_rows(&zeroed); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } -static int test_open_file_ex(const api_t* api, const char* fixture) +static int test_rows_open_close_and_cursor_outlives_workbook(const api_t* api, const char* fixture) +{ + xl_workbook* handle = NULL; + CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); + + xl_rows* rows = (xl_rows*)(uintptr_t)1; + CHECK(api->rows_open(handle, 0, NULL) == XL_INVALID_ARGUMENT, "xl_rows_open must reject a NULL out_rows"); + CHECK(api->rows_open(handle, -1, &rows) == XL_INVALID_ARGUMENT, "xl_rows_open must reject a negative sheet"); + CHECK(rows == NULL, "a rejected xl_rows_open must null the out cursor"); + CHECK(api->rows_open(handle, 1, &rows) == XL_ERROR, "xl_rows_open must reject a sheet past xl_sheet_count"); + CHECK(rows == NULL, "a rejected xl_rows_open must null the out cursor"); + CHECK(api->rows_close(NULL) == XL_INVALID_HANDLE, "xl_rows_close(NULL) must be XL_INVALID_HANDLE"); + + xl_rows* first = NULL; + xl_rows* second = NULL; + CHECK(api->rows_open(handle, 0, &first) == XL_OK, "xl_rows_open must succeed"); + CHECK(api->rows_open(handle, 0, &second) == XL_OK, "a second cursor on the same sheet must open"); + + xl_row row = {0}; + CHECK(api->rows_next_view(first, &row) == XL_OK && row.cell_count == 18, "the first cursor must read the header row"); + CHECK(api->rows_next_view(second, &row) == XL_OK && row.cell_count == 18, + "the second cursor must read the header row independently"); + CHECK(api->rows_close(second) == XL_OK, "xl_rows_close must succeed"); + CHECK(api->rows_close(second) == XL_INVALID_HANDLE, "a second xl_rows_close on one cursor must be XL_INVALID_HANDLE"); + + CHECK(api->close_(handle) == XL_OK, "xl_close must succeed while a cursor is open"); + int32_t sheet_count = 0; + CHECK(api->sheet_count(handle, &sheet_count) == XL_INVALID_HANDLE, "a closed workbook handle must be XL_INVALID_HANDLE"); + + int row_count = 1; + for (;;) + { + int32_t status = api->rows_next_view(first, &row); + if (status == XL_EOF) + { + break; + } + CHECK(status == XL_OK, "a cursor must keep reading after xl_close on its workbook"); + row_count++; + } + CHECK(row_count == 101, "a cursor opened before xl_close must still see all 101 rows"); + CHECK(api->rows_close(first) == XL_OK, "xl_rows_close must succeed after the workbook is closed"); + return 0; +} + +static int test_open_file_options(const api_t* api, const char* fixture) { xl_workbook* handle = NULL; size_t path_len = strlen(fixture); - CHECK(api->open_file_ex((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, NULL, &handle) == XL_OK, - "xl_open_file_ex with NULL options must succeed like xl_open_file"); + CHECK(api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, NULL, &handle) == XL_OK, + "xl_open_file with NULL options must succeed"); int32_t sheet_count = 0; CHECK(api->sheet_count(handle, &sheet_count) == XL_OK && sheet_count == 1, - "a workbook opened via xl_open_file_ex(NULL) must behave normally"); + "a workbook opened with NULL options must behave normally"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); xl_open_options bad_options; memset(&bad_options, 0, sizeof(bad_options)); bad_options.struct_size = 999999; handle = NULL; - int32_t status = api->open_file_ex((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, &bad_options, &handle); + int32_t status = api->open_file((const uint8_t*)fixture, (int32_t)path_len, XL_FORMAT_XLSB, &bad_options, &handle); CHECK(status == XL_INVALID_ARGUMENT, "a wrong xl_open_options.struct_size must be XL_INVALID_ARGUMENT"); - CHECK(handle == NULL, "a rejected xl_open_file_ex must not hand back a handle"); + CHECK(handle == NULL, "a rejected xl_open_file must not hand back a handle"); return 0; } @@ -459,33 +539,33 @@ static int test_parse_rejects_hostile_counts(const api_t* api, const char* fixtu xl_table table; memset(&table, 0, sizeof(table)); - CHECK(api->parse_typed(handle, &one_spec, INT32_MAX, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &one_spec, INT32_MAX, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject spec_count == INT32_MAX"); CHECK(table.columns == NULL, "a rejected xl_parse_typed must leave the out table zeroed"); - CHECK(api->parse_typed(handle, &one_spec, XL_MAX_COLUMN_SPECS + 1, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &one_spec, XL_MAX_COLUMN_SPECS + 1, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject spec_count one past XL_MAX_COLUMN_SPECS"); - CHECK(api->parse_typed(handle, &one_spec, 0, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &one_spec, 0, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject spec_count == 0"); - CHECK(api->parse_typed(handle, &one_spec, -1, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &one_spec, -1, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject a negative spec_count"); xl_column_spec wide_name = one_spec; int32_t wide_name_len = XL_MAX_COLUMN_NAME_BYTES + 1; wide_name.name_lens = &wide_name_len; - CHECK(api->parse_typed(handle, &wide_name, 1, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &wide_name, 1, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject a name_len past XL_MAX_COLUMN_NAME_BYTES"); wide_name_len = -1; wide_name.name_lens = &wide_name_len; - CHECK(api->parse_typed(handle, &wide_name, 1, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &wide_name, 1, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject a negative name_len"); struct ArrowArray array; struct ArrowSchema schema; memset(&array, 0, sizeof(array)); memset(&schema, 0, sizeof(schema)); - CHECK(api->parse_arrow(handle, &one_spec, INT32_MAX, 1, &array, &schema) == XL_INVALID_ARGUMENT, + CHECK(api->parse_arrow(handle, 0, &one_spec, INT32_MAX, 1, 1, &array, &schema) == XL_INVALID_ARGUMENT, "xl_parse_arrow must reject spec_count == INT32_MAX"); CHECK(array.release == NULL && schema.release == NULL, "a rejected xl_parse_arrow must leave both out params releasable-as-no-op"); @@ -496,14 +576,14 @@ static int test_parse_rejects_hostile_counts(const api_t* api, const char* fixtu int32_t blank_name_len; set_spec_name1(&blank_name, &blank_name_name, &blank_name_len, " "); blank_name.type = XL_T_STRING; - CHECK(api->parse_typed(handle, &blank_name, 1, 1, &table) == XL_INVALID_ARGUMENT, + CHECK(api->parse_typed(handle, 0, &blank_name, 1, 1, 1, &table) == XL_INVALID_ARGUMENT, "xl_parse_typed must reject a blank column name"); xl_column_spec specs[3]; const uint8_t* name_ptrs[3]; int32_t name_lens[3]; build_specs(specs, name_ptrs, name_lens); - CHECK(api->parse_typed(handle, specs, 3, 1, &table) == XL_OK, + CHECK(api->parse_typed(handle, 0, specs, 3, 1, 1, &table) == XL_OK, "a valid xl_parse_typed after the rejections must still succeed"); CHECK(table.row_count == 100, "the recovered parse must still return all 100 data rows"); api->free_table(&table); @@ -517,10 +597,13 @@ static int test_parse_typed_and_cursor_independence(const api_t* api, const char xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); + xl_rows* rows = NULL; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + uint8_t scratch[4096]; int32_t written = 0; - CHECK(api->next_row(handle, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, - "reading the header row via xl_next_row must succeed"); + CHECK(api->rows_next(rows, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, + "reading the header row via xl_rows_next must succeed"); xl_column_spec specs[3]; const uint8_t* name_ptrs[3]; @@ -529,7 +612,7 @@ static int test_parse_typed_and_cursor_independence(const api_t* api, const char xl_table table; memset(&table, 0, sizeof(table)); - CHECK(api->parse_typed(handle, specs, 3, 1, &table) == XL_OK, "xl_parse_typed must succeed"); + CHECK(api->parse_typed(handle, 0, specs, 3, 1, 1, &table) == XL_OK, "xl_parse_typed must succeed"); CHECK(table.column_count == 3, "xl_parse_typed must return exactly the requested columns"); CHECK(table.row_count == 100, "xl_parse_typed must return all 100 data rows, independent of the row cursor"); @@ -550,15 +633,16 @@ static int test_parse_typed_and_cursor_independence(const api_t* api, const char api->free_table(&table); - CHECK(api->next_row(handle, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, - "xl_next_row after xl_parse_typed must still succeed"); + CHECK(api->rows_next(rows, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, + "xl_rows_next after xl_parse_typed must still succeed"); int32_t cell_count = 0; memcpy(&cell_count, scratch, sizeof(int32_t)); int32_t value_len = 0; memcpy(&value_len, scratch + 12, sizeof(int32_t)); CHECK(cell_count == 18 && value_len == 6 && memcmp(scratch + 16, "Valor1", 6) == 0, - "xl_parse_typed must not have disturbed the xl_next_row cursor - this must be the first data row"); + "xl_parse_typed must not have disturbed the xl_rows_next cursor - this must be the first data row"); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } @@ -593,7 +677,7 @@ static int test_parse_arrow(const api_t* api, const char* fixture) struct ArrowSchema schema; memset(&array, 0, sizeof(array)); memset(&schema, 0, sizeof(schema)); - CHECK(api->parse_arrow(handle, specs, 3, 1, &array, &schema) == XL_OK, "xl_parse_arrow must succeed"); + CHECK(api->parse_arrow(handle, 0, specs, 3, 1, 1, &array, &schema) == XL_OK, "xl_parse_arrow must succeed"); CHECK(strcmp(schema.format, "+s") == 0, "the top-level Arrow schema must be a struct (\"+s\")"); CHECK(schema.n_children == 3, "xl_parse_arrow must export exactly the requested columns"); @@ -627,14 +711,17 @@ static int test_infer_schema(const api_t* api, const char* fixture) xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); + xl_rows* rows = NULL; + CHECK(api->rows_open(handle, 0, &rows) == XL_OK, "xl_rows_open must succeed"); + uint8_t scratch[4096]; int32_t written = 0; - CHECK(api->next_row(handle, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, - "reading the header row via xl_next_row must succeed"); + CHECK(api->rows_next(rows, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, + "reading the header row via xl_rows_next must succeed"); xl_inferred_schema schema; memset(&schema, 0, sizeof(schema)); - CHECK(api->infer_schema(handle, 1, 100, &schema) == XL_OK, "xl_infer_schema must succeed"); + CHECK(api->infer_schema(handle, 0, 1, 100, 0, &schema) == XL_OK, "xl_infer_schema must succeed"); CHECK(schema.column_count == 18, "RealExcel.xlsb's header row has 18 columns"); xl_column_spec coluna1 = schema.columns[0]; @@ -650,24 +737,25 @@ static int test_infer_schema(const api_t* api, const char* fixture) CHECK(coluna3.name_count == 1 && coluna3.name_lens[0] == 7 && memcmp(coluna3.names[0], "Coluna3", 7) == 0, "column 2 must be named Coluna3"); CHECK(coluna3.type == XL_T_I64, "Coluna3 must be guessed as XL_T_I64 - every sampled value is a whole number"); - CHECK(api->next_row(handle, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, - "xl_next_row after xl_infer_schema must still succeed"); + CHECK(api->rows_next(rows, scratch, (int32_t)sizeof(scratch), &written) == XL_OK, + "xl_rows_next after xl_infer_schema must still succeed"); int32_t value_len = 0; memcpy(&value_len, scratch + 12, sizeof(int32_t)); CHECK(value_len == 6 && memcmp(scratch + 16, "Valor1", 6) == 0, - "xl_infer_schema must not have disturbed the xl_next_row cursor - this must be the first data row"); + "xl_infer_schema must not have disturbed the xl_rows_next cursor - this must be the first data row"); xl_column_spec first_three[3]; memcpy(first_three, schema.columns, 3 * sizeof(xl_column_spec)); xl_table table; memset(&table, 0, sizeof(table)); - CHECK(api->parse_typed(handle, first_three, 3, 1, &table) == XL_OK, + CHECK(api->parse_typed(handle, 0, first_three, 3, 1, 1, &table) == XL_OK, "an inferred schema must be directly usable by xl_parse_typed"); api->free_schema(&schema); CHECK(table.row_count == 100, "xl_parse_typed with the inferred schema must return all 100 data rows"); api->free_table(&table); + CHECK(api->rows_close(rows) == XL_OK, "xl_rows_close must succeed"); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } @@ -679,13 +767,13 @@ static int test_infer_schema_rejects_bad_arguments(const api_t* api, const char* xl_inferred_schema schema; memset(&schema, 0, sizeof(schema)); - CHECK(api->infer_schema(handle, -1, 100, &schema) == XL_INVALID_ARGUMENT, + CHECK(api->infer_schema(handle, 0, -1, 100, 0, &schema) == XL_INVALID_ARGUMENT, "xl_infer_schema must reject a negative header_row"); CHECK(schema.columns == NULL, "a rejected xl_infer_schema must leave the out schema zeroed"); - CHECK(api->infer_schema(handle, 1, 0, &schema) == XL_INVALID_ARGUMENT, + CHECK(api->infer_schema(handle, 0, 1, 0, 0, &schema) == XL_INVALID_ARGUMENT, "xl_infer_schema must reject sample_size == 0"); - CHECK(api->infer_schema(handle, 1, -1, &schema) == XL_INVALID_ARGUMENT, + CHECK(api->infer_schema(handle, 0, 1, -1, 0, &schema) == XL_INVALID_ARGUMENT, "xl_infer_schema must reject a negative sample_size"); xl_inferred_schema zeroed; @@ -705,7 +793,7 @@ static int test_infer_schema_parse_text(const api_t* api) fclose(csv); xl_workbook* handle = NULL; - int32_t status = api->open_file((const uint8_t*)csv_path, (int32_t)strlen(csv_path), XL_FORMAT_CSV, &handle); + int32_t status = api->open_file((const uint8_t*)csv_path, (int32_t)strlen(csv_path), XL_FORMAT_CSV, NULL, &handle); int32_t typed = XL_ERROR; int32_t rejected = XL_OK; int32_t types[4] = {-1, -1, -1, -1}; @@ -714,7 +802,7 @@ static int test_infer_schema_parse_text(const api_t* api) { xl_inferred_schema schema; memset(&schema, 0, sizeof(schema)); - typed = api->infer_schema_ex(handle, 1, 100, XL_INFER_PARSE_TEXT, &schema); + typed = api->infer_schema(handle, 0, 1, 100, XL_INFER_PARSE_TEXT, &schema); if (typed == XL_OK) { column_count = schema.column_count; @@ -725,17 +813,17 @@ static int test_infer_schema_parse_text(const api_t* api) api->free_schema(&schema); } memset(&schema, 0, sizeof(schema)); - rejected = api->infer_schema_ex(handle, 1, 100, 2, &schema); + rejected = api->infer_schema(handle, 0, 1, 100, 2, &schema); api->close_(handle); } remove(csv_path); CHECK(status == XL_OK, "xl_open_file must open the infer-schema CSV"); - CHECK(typed == XL_OK, "xl_infer_schema_ex must succeed with XL_INFER_PARSE_TEXT"); + CHECK(typed == XL_OK, "xl_infer_schema must succeed with XL_INFER_PARSE_TEXT"); CHECK(column_count == 4, "the infer-schema CSV has 4 columns"); CHECK(types[0] == XL_T_I64 && types[1] == XL_T_F64 && types[2] == XL_T_BOOL && types[3] == XL_T_DATE, - "xl_infer_schema_ex must type the CSV's columns as I64, F64, BOOL, DATE"); - CHECK(rejected == XL_INVALID_ARGUMENT, "xl_infer_schema_ex must reject an unknown flag bit"); + "xl_infer_schema must type the CSV's columns as I64, F64, BOOL, DATE"); + CHECK(rejected == XL_INVALID_ARGUMENT, "xl_infer_schema must reject an unknown flag bit"); return 0; } @@ -789,11 +877,11 @@ static int test_write_typed(const api_t* api) XL_FORMAT_CSV, &spec, &table, &options); CHECK(status == XL_OK, "xl_write_typed should write a CSV"); - status = api->open_file((const uint8_t*)out_path, (int32_t)strlen(out_path), XL_FORMAT_CSV, &handle); + status = api->open_file((const uint8_t*)out_path, (int32_t)strlen(out_path), XL_FORMAT_CSV, NULL, &handle); CHECK(status == XL_OK, "the written CSV should reopen"); memset(&read_back, 0, sizeof(read_back)); - status = api->parse_typed(handle, &spec, 1, 1, &read_back); + status = api->parse_typed(handle, 0, &spec, 1, 1, 1, &read_back); CHECK(status == XL_OK, "the written CSV should parse back"); CHECK(read_back.row_count == 2, "the written CSV should hold two rows"); CHECK(((const int64_t*)read_back.columns[0].values)[0] == 3, "row 0 should round-trip as 3"); @@ -1059,7 +1147,7 @@ static int test_csv_aggregate_file(xl_lib_handle lib) return status; } -static int test_parse_typed_ex(const api_t* api, const char* fixture) +static int test_parse_typed_degree_of_parallelism(const api_t* api, const char* fixture) { xl_workbook* handle = NULL; CHECK(open_fixture(api, fixture, &handle) == XL_OK, "xl_open_file must succeed"); @@ -1071,26 +1159,26 @@ static int test_parse_typed_ex(const api_t* api, const char* fixture) xl_table table; memset(&table, 0, sizeof(table)); - CHECK(api->parse_typed_ex(handle, specs, 3, 1, 0, NULL) == XL_INVALID_ARGUMENT, - "xl_parse_typed_ex must reject a NULL out_table"); - CHECK(api->parse_typed_ex(handle, specs, 3, 1, -1, &table) == XL_INVALID_ARGUMENT, - "xl_parse_typed_ex must reject a negative degree_of_parallelism"); - CHECK(api->parse_typed_ex(handle, specs, 3, 1, 0, &table) == XL_OK, - "xl_parse_typed_ex must read a non-CSV workbook sequentially"); - CHECK(table.row_count == 100, "xl_parse_typed_ex must return all 100 data rows"); + CHECK(api->parse_typed(handle, 0, specs, 3, 1, 0, NULL) == XL_INVALID_ARGUMENT, + "xl_parse_typed must reject a NULL out_table"); + CHECK(api->parse_typed(handle, 0, specs, 3, 1, -1, &table) == XL_INVALID_ARGUMENT, + "xl_parse_typed must reject a negative degree_of_parallelism"); + CHECK(api->parse_typed(handle, 0, specs, 3, 1, 0, &table) == XL_OK, + "xl_parse_typed must read a non-CSV workbook sequentially"); + CHECK(table.row_count == 100, "xl_parse_typed must return all 100 data rows"); api->free_table(&table); CHECK(api->close_(handle) == XL_OK, "xl_close must succeed"); return 0; } -static int test_parse_typed_ex_csv(const api_t* api) +static int test_parse_typed_degree_of_parallelism_csv(const api_t* api) { const char* csv_path = "smoke_parallel_typed.csv"; CHECK(write_csv_fixture(csv_path, SMOKE_CSV_ROW_COUNT) == 0, "cannot write the parallel typed fixture"); xl_workbook* handle = NULL; - int32_t status = api->open_file((const uint8_t*)csv_path, (int32_t)strlen(csv_path), XL_FORMAT_CSV, &handle); + int32_t status = api->open_file((const uint8_t*)csv_path, (int32_t)strlen(csv_path), XL_FORMAT_CSV, NULL, &handle); int64_t rows = -1; int64_t total = 0; if (status == XL_OK) @@ -1104,7 +1192,7 @@ static int test_parse_typed_ex_csv(const api_t* api) xl_table table; memset(&table, 0, sizeof(table)); - status = api->parse_typed_ex(handle, &spec, 1, 1, 0, &table); + status = api->parse_typed(handle, 0, &spec, 1, 1, 0, &table); if (status == XL_OK) { rows = table.row_count; @@ -1119,9 +1207,9 @@ static int test_parse_typed_ex_csv(const api_t* api) } remove(csv_path); - CHECK(status == XL_OK, "xl_parse_typed_ex must parse a CSV handle"); - CHECK(rows == SMOKE_CSV_ROW_COUNT, "xl_parse_typed_ex must return every CSV data row"); - CHECK(total == SMOKE_CSV_EXPECTED_TOTAL, "xl_parse_typed_ex must return every CSV value exactly once"); + CHECK(status == XL_OK, "xl_parse_typed must parse a CSV handle"); + CHECK(rows == SMOKE_CSV_ROW_COUNT, "xl_parse_typed must return every CSV data row"); + CHECK(total == SMOKE_CSV_EXPECTED_TOTAL, "xl_parse_typed must return every CSV value exactly once"); return 0; } @@ -1203,15 +1291,16 @@ int main(int argc, char** argv) failures += test_abi_version(&api); failures += test_open_missing_file_reports_an_error(&api); failures += test_sheets_and_flags(&api, fixture_path); - failures += test_next_row_blob_and_growth(&api, fixture_path); - failures += test_next_row_view(&api, fixture_path); + failures += test_rows_next_blob_and_growth(&api, fixture_path); + failures += test_rows_next_view(&api, fixture_path); failures += test_read_all_blob_and_decoded(&api, fixture_path); - failures += test_open_file_ex(&api, fixture_path); + failures += test_rows_open_close_and_cursor_outlives_workbook(&api, fixture_path); + failures += test_open_file_options(&api, fixture_path); failures += test_parse_rejects_hostile_counts(&api, fixture_path); failures += test_parse_typed_and_cursor_independence(&api, fixture_path); failures += test_parse_arrow(&api, fixture_path); - failures += test_parse_typed_ex(&api, fixture_path); - failures += test_parse_typed_ex_csv(&api); + failures += test_parse_typed_degree_of_parallelism(&api, fixture_path); + failures += test_parse_typed_degree_of_parallelism_csv(&api); failures += test_infer_schema(&api, fixture_path); failures += test_infer_schema_rejects_bad_arguments(&api, fixture_path); failures += test_infer_schema_parse_text(&api); diff --git a/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs b/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs index c0ff90de..760c9dce 100644 --- a/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs +++ b/tests/ExcelReader.Tests/Native/ExportsOutParamTests.cs @@ -32,6 +32,19 @@ public void ABufferExportRejectingItsArgumentsStillZeroesTheLengthOutParam(strin Assert.Equal(0, outValue); } + [Fact] + public void AReadingExportRejectingAnUnknownHandleDoesNotLeaveAnEarlierCallsError() + { + NativeApi.SetLastError("stale"); + byte* buffer = stackalloc byte[1]; + int written = Sentinel; + + int status = ((delegate* unmanaged)&Exports.RowsNext)(0, buffer, 1, &written); + + Assert.Equal(NativeStatus.InvalidHandle, status); + Assert.Empty(NativeApi.LastErrorText()); + } + [Fact] public void SheetNameAtRejectingItsArgumentsStillZeroesTheLengthOutParam() { diff --git a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs index 3b6a06bf..a8d987b2 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiConcurrencyTests.cs @@ -123,16 +123,26 @@ public void TypedParsesOfEverySheetOfOneWorkbookRunInParallel() Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(bytes, NativeFormat.Xlsx, out NativeHandle? handle)); using NativeHandle live = handle!; long[] rowCounts = new long[ConcurrentSheetTests.Sheets]; + long[] firstValues = new long[ConcurrentSheetTests.Sheets]; Parallel.For(0, rowCounts.Length, s => { NativeColumnSpec[] specs = [new() { Index = 1, Type = NativeColumnType.Int64 }]; Assert.Equal(NativeStatus.Ok, TypedApi.ParseTyped(live, s, specs, headerRow: 0, out NativeTable table)); - rowCounts[s] = table.RowCount; - TypedApi.FreeTable(ref table); + try + { + rowCounts[s] = table.RowCount; + NativeColumn column = Marshal.PtrToStructure(table.Columns); + firstValues[s] = Marshal.ReadInt64(column.Values); + } + finally + { + TypedApi.FreeTable(ref table); + } }); Assert.All(rowCounts, count => Assert.Equal(ConcurrentSheetTests.Rows, count)); + Assert.Equal(Enumerable.Range(0, firstValues.Length).Select(s => s * 1_000_000L), firstValues); } [Fact] @@ -150,8 +160,13 @@ public void AClosedWorkbookIdStaysInvalidWhileItsCursorReadsToTheEnd() Assert.Null(Exports.Resolve(id)); Assert.Equal(NativeStatus.InvalidHandle, ReadApi.OpenRows(Exports.Resolve(id), 0, out _)); int rows = 0; - while (ReadApi.NextRowView(cursor, out _) == NativeStatus.Ok) + while (ReadApi.NextRowView(cursor, out NativeRow row) == NativeStatus.Ok) { + if (rows == 0) + { + NativeRowCell first = Marshal.PtrToStructure(row.Cells); + Assert.StartsWith("s3-name-", Marshal.PtrToStringUTF8(first.Value, first.ValueLength), StringComparison.Ordinal); + } rows++; } Assert.Equal(ConcurrentSheetTests.Rows, rows); diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs index b329b507..f2e00456 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.InferSchemaText.cs @@ -55,31 +55,6 @@ public void InferSchema_Should_Type_Text_Cells_When_ParseText_Is_Set() } } - [Fact] - public void InferSchema_Should_Match_The_Flagless_Result_When_Flags_Are_Zero() - { - using MemoryStream ms = BuildInferSchemaFixture(); - Assert.Equal(NativeStatus.Ok, ReadApi.OpenMemory(ms.ToArray(), NativeFormat.Xlsx, out NativeHandle? handle)); - try - { - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema plain)); - Assert.Equal(NativeStatus.Ok, ReadApi.InferSchema(handle, 0, headerRow: 1, sampleSize: 100, 0, out NativeInferredSchema flagged)); - try - { - Assert.Equal(DecodeSchema(plain), DecodeSchema(flagged)); - } - finally - { - ReadApi.FreeSchema(ref plain); - ReadApi.FreeSchema(ref flagged); - } - } - finally - { - ReadApi.Close(handle); - } - } - [Fact] public void InferSchema_Should_Reject_Unknown_Flags() { diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs index 7b74cf93..c3f8734d 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Reading.cs @@ -225,7 +225,7 @@ public void OpenMemory_Should_Open_Xlsx_From_A_Copy_Of_The_Bytes() } [Fact] - public void OpenFileEx_With_Null_Options_Behaves_Like_OpenFile() + public void OpenFile_With_Null_Options_Uses_The_Defaults() { int status = ReadApi.OpenFile(Encoding.UTF8.GetBytes(XlsxFixture), NativeFormat.Auto, null, out NativeHandle? handle); @@ -235,7 +235,7 @@ public void OpenFileEx_With_Null_Options_Behaves_Like_OpenFile() } [Fact] - public void OpenMemoryEx_With_Null_Options_Behaves_Like_OpenMemory() + public void OpenMemory_With_Null_Options_Uses_The_Defaults() { byte[] bytes = File.ReadAllBytes(XlsxFixture); @@ -249,7 +249,7 @@ public void OpenMemoryEx_With_Null_Options_Behaves_Like_OpenMemory() [Theory] [InlineData(-1)] [InlineData(256)] - public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Delimiter(int delimiter) + public void OpenFile_Rejects_An_Out_Of_Range_Csv_Delimiter(int delimiter) { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvDelimiter = delimiter }; @@ -260,7 +260,7 @@ public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Delimiter(int delimiter) } [Fact] - public void OpenFileEx_Rejects_A_Negative_Numeric_Option() + public void OpenFile_Rejects_A_Negative_Numeric_Option() { NativeOpenOptionsRaw options = DefaultRawOptions() with { MaxZipEntries = -5 }; @@ -271,7 +271,7 @@ public void OpenFileEx_Rejects_A_Negative_Numeric_Option() } [Fact] - public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Sniff_Dialect_State() + public void OpenFile_Rejects_An_Out_Of_Range_Csv_Sniff_Dialect_State() { NativeOpenOptionsRaw options = DefaultRawOptions() with { CsvSniffDialect = 99 }; @@ -282,7 +282,7 @@ public void OpenFileEx_Rejects_An_Out_Of_Range_Csv_Sniff_Dialect_State() } [Fact] - public void OpenFileEx_Applies_An_Explicit_Csv_Delimiter() + public void OpenFile_Applies_An_Explicit_Csv_Delimiter() { string path = Path.Combine(Path.GetTempPath(), $"excelreader-native-{Guid.NewGuid():N}.csv"); File.WriteAllText(path, "name;qty\nwidget;7\n"); @@ -312,7 +312,7 @@ public void OpenFileEx_Applies_An_Explicit_Csv_Delimiter() } [Fact] - public void OpenFileEx_Sniffs_The_Csv_Dialect_When_Requested() + public void OpenFile_Sniffs_The_Csv_Dialect_When_Requested() { string path = Path.Combine(Path.GetTempPath(), $"excelreader-native-{Guid.NewGuid():N}.csv"); File.WriteAllText(path, "name;qty\nwidget;7\ngadget;9\n"); @@ -342,7 +342,7 @@ public void OpenFileEx_Sniffs_The_Csv_Dialect_When_Requested() } [Fact] - public void OpenFileEx_Applies_A_Tiny_Max_Total_Decompressed_Bytes_To_A_Real_Xlsx() + public void OpenFile_Applies_A_Tiny_Max_Total_Decompressed_Bytes_To_A_Real_Xlsx() { NativeOpenOptionsRaw options = DefaultRawOptions() with { MaxTotalDecompressedBytes = 1 }; @@ -353,7 +353,7 @@ public void OpenFileEx_Applies_A_Tiny_Max_Total_Decompressed_Bytes_To_A_Real_Xls } [Fact] - public void OpenFileEx_Applies_A_Tiny_Csv_Max_Cell_Bytes() + public void OpenFile_Applies_A_Tiny_Csv_Max_Cell_Bytes() { string path = Path.Combine(Path.GetTempPath(), $"excelreader-native-{Guid.NewGuid():N}.csv"); File.WriteAllText(path, "name\n" + new string('x', 100_000) + "\n"); @@ -608,6 +608,10 @@ public void Sheet_Functions_Should_Reject_A_Null_Handle() { Assert.Equal(NativeStatus.InvalidHandle, ReadApi.SheetCount(null, out _)); Assert.Equal(NativeStatus.InvalidHandle, ReadApi.IsDate1904(null, out _)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.SheetVisibilityAt(null, 0, out _)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.SheetIndex(null, [], out _)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.OpenRows(null, 0, out _)); + Assert.Equal(NativeStatus.InvalidHandle, ReadApi.CloseRows(null)); } [Fact] diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs index 0d0130d7..fde77324 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.Sheets.cs @@ -46,12 +46,20 @@ public async Task EverySheetRead_Should_Reject_A_Sheet_Index_Out_Of_Range(int sh { using NativeHandle handle = await OpenTwoSheetsAsync(); + NativeApi.ClearLastError(); Assert.Equal(expected, TypedApi.ParseTyped(handle, sheet, FirstColumnAsText(), headerRow: 0, out _)); Assert.NotEmpty(NativeApi.LastErrorText()); + NativeApi.ClearLastError(); Assert.Equal(expected, TypedApi.OpenTypedReader(handle, sheet, FirstColumnAsText(), headerRow: 0, maxRows: 0, out nint reader)); Assert.Equal(0, reader); + Assert.NotEmpty(NativeApi.LastErrorText()); + NativeApi.ClearLastError(); Assert.Equal(expected, ArrowApi.OpenArrowStream(handle, sheet, FirstColumnAsText(), headerRow: 0, maxRows: 0, out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); + NativeApi.ClearLastError(); Assert.Equal(expected, ArrowApi.ParseArrow(handle, sheet, FirstColumnAsText(), headerRow: 0, out _, out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); + NativeApi.ClearLastError(); Assert.Equal(expected, ReadApi.InferSchema(handle, sheet, headerRow: 0, sampleSize: 10, flags: 0, out _)); Assert.NotEmpty(NativeApi.LastErrorText()); } @@ -125,6 +133,10 @@ public async Task OpenRows_And_SheetVisibilityAt_Should_Reject_An_Index_Out_Of_R Assert.Equal(expected, ReadApi.OpenRows(handle, sheet, out NativeRowCursor? cursor)); Assert.Null(cursor); Assert.Equal(expected, ReadApi.SheetVisibilityAt(handle, sheet, out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); + NativeApi.ClearLastError(); + Assert.Equal(expected, ReadApi.SheetNameAt(handle, sheet, new byte[64], out _)); + Assert.NotEmpty(NativeApi.LastErrorText()); } [Fact] diff --git a/tests/ExcelReader.Tests/Native/NativeApiTests.cs b/tests/ExcelReader.Tests/Native/NativeApiTests.cs index 81c8b988..b5f38724 100644 --- a/tests/ExcelReader.Tests/Native/NativeApiTests.cs +++ b/tests/ExcelReader.Tests/Native/NativeApiTests.cs @@ -300,7 +300,7 @@ public void OpenFile_Should_Reject_Unknown_Format_Code() } [Fact] - public void OpenFileEx_With_An_Unrecognized_Struct_Size_Is_Invalid_Argument() + public void OpenFile_With_An_Unrecognized_Struct_Size_Is_Invalid_Argument() { NativeOpenOptionsRaw options = DefaultRawOptions() with { StructSize = 1 }; From 9eb7539cdfe0593008579a5ffb048772929cc349 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:46:54 -0300 Subject: [PATCH 28/49] feat(cpp)!: read through sheets over C ABI v6 xl::Workbook hands out xl::Sheet values; rows(), read_all_decoded() and infer_schema() move to the sheet, and parse_sheet, typed_reader, parse_arrow and arrow_stream take one. xl::RowCursor owns its native cursor. move_to_sheet and the index-less sheet_name are removed. Co-Authored-By: Claude Sonnet 5.5 --- cpp/benchmarks/benchmark_compare.cpp | 4 +- cpp/benchmarks/benchmark_csv_compare.cpp | 10 +- cpp/benchmarks/benchmark_workbook.cpp | 10 +- cpp/benchmarks/benchmark_write.cpp | 2 +- cpp/benchmarks/benchmark_write_compare.cpp | 2 +- cpp/tests/CMakeLists.txt | 7 + cpp/tests/arrow.cpp | 44 ++- cpp/tests/batch.cpp | 41 +-- cpp/tests/rows.cpp | 35 ++- cpp/tests/sheets.cpp | 221 +++++++++++++++ cpp/tests/smoke.cpp | 15 +- cpp/tests/write.cpp | 24 +- .../include/excelreader.hpp | 259 +++++++++++++----- .../include/excelreader_arrow.hpp | 11 +- 14 files changed, 509 insertions(+), 176 deletions(-) create mode 100644 cpp/tests/sheets.cpp diff --git a/cpp/benchmarks/benchmark_compare.cpp b/cpp/benchmarks/benchmark_compare.cpp index 2b9e6b0d..39fdb096 100644 --- a/cpp/benchmarks/benchmark_compare.cpp +++ b/cpp/benchmarks/benchmark_compare.cpp @@ -109,7 +109,7 @@ static void BM_ExcelReader_Xlsx_Full(benchmark::State &state) state.SkipWithError(workbook.error().message); return; } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); if (!table.has_value()) { state.SkipWithError(table.error().message); @@ -141,7 +141,7 @@ static void BM_ExcelReader_Xlsx_ParseOnly(benchmark::State &state) state.SkipWithError(workbook.error().message); return; } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); if (!table.has_value()) { state.SkipWithError(table.error().message); diff --git a/cpp/benchmarks/benchmark_csv_compare.cpp b/cpp/benchmarks/benchmark_csv_compare.cpp index 49b5350c..cb0eb531 100644 --- a/cpp/benchmarks/benchmark_csv_compare.cpp +++ b/cpp/benchmarks/benchmark_csv_compare.cpp @@ -214,7 +214,7 @@ static void BM_ExcelReader_Csv_Full(benchmark::State &state) state.SkipWithError(workbook.error().message); return; } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); if (!table.has_value()) { state.SkipWithError(table.error().message); @@ -265,7 +265,13 @@ static void BM_ExcelReader_Csv_Cells(benchmark::State &state) state.SkipWithError(workbook.error().message); return; } - xl::RowCursor cursor = workbook->rows(); + auto opened = workbook->sheet(0).rows(); + if (!opened.has_value()) + { + state.SkipWithError(opened.error().message); + return; + } + xl::RowCursor &cursor = *opened; int64_t acc = 0; while (true) { diff --git a/cpp/benchmarks/benchmark_workbook.cpp b/cpp/benchmarks/benchmark_workbook.cpp index 01cfa70a..a80e4e5c 100644 --- a/cpp/benchmarks/benchmark_workbook.cpp +++ b/cpp/benchmarks/benchmark_workbook.cpp @@ -125,7 +125,7 @@ static void BM_ParseSheet(benchmark::State &state) } state.ResumeTiming(); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); benchmark::DoNotOptimize(table); if (!table.has_value()) { @@ -155,7 +155,7 @@ static void BM_InferSchema(benchmark::State &state) } state.ResumeTiming(); - auto schema = workbook->infer_schema(1, 100); + auto schema = workbook->sheet(0).infer_schema(1, 100); benchmark::DoNotOptimize(schema); if (!schema.has_value()) { @@ -207,7 +207,7 @@ static void BM_ParseSheet_Large(benchmark::State &state) } state.ResumeTiming(); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); benchmark::DoNotOptimize(table); if (!table.has_value()) { @@ -237,7 +237,7 @@ static void BM_TypedReaderBatched_Large(benchmark::State &state) } state.ResumeTiming(); - auto reader = xl::typed_reader(*workbook, 1, state.range(0)); + auto reader = xl::typed_reader(workbook->sheet(0), 1, state.range(0)); if (!reader.has_value()) { state.SkipWithError(reader.error().message.c_str()); @@ -277,7 +277,7 @@ static void BM_InferSchema_Large(benchmark::State &state) } state.ResumeTiming(); - auto schema = workbook->infer_schema(1, 1000); + auto schema = workbook->sheet(0).infer_schema(1, 1000); benchmark::DoNotOptimize(schema); if (!schema.has_value()) { diff --git a/cpp/benchmarks/benchmark_write.cpp b/cpp/benchmarks/benchmark_write.cpp index 79bafb0c..40aea04c 100644 --- a/cpp/benchmarks/benchmark_write.cpp +++ b/cpp/benchmarks/benchmark_write.cpp @@ -45,7 +45,7 @@ static const std::vector &fixture_rows() std::fprintf(stderr, "missing or unreadable fixture %s\n", EXCELREADER_LARGE_FIXTURE_PATH); std::abort(); } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); if (!table.has_value() || table->size() == 0) { std::fprintf(stderr, "fixture %s parsed to zero rows\n", EXCELREADER_LARGE_FIXTURE_PATH); diff --git a/cpp/benchmarks/benchmark_write_compare.cpp b/cpp/benchmarks/benchmark_write_compare.cpp index fbe98f53..0e9eb191 100644 --- a/cpp/benchmarks/benchmark_write_compare.cpp +++ b/cpp/benchmarks/benchmark_write_compare.cpp @@ -83,7 +83,7 @@ namespace std::fprintf(stderr, "missing or unreadable fixture %s\n", EXCELREADER_XLSX_FIXTURE_PATH); std::abort(); } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); if (!table.has_value() || table->size() == 0) { std::fprintf(stderr, "fixture %s parsed to zero rows\n", EXCELREADER_XLSX_FIXTURE_PATH); diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index c16ffcee..3e166f8a 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -38,6 +38,13 @@ target_compile_definitions(excelreader_cpp_rows PRIVATE add_test(NAME excelreader_cpp_rows COMMAND excelreader_cpp_rows) excelreader_copy_native_library(excelreader_cpp_rows) +add_executable(excelreader_cpp_sheets sheets.cpp) +find_package(Threads REQUIRED) +target_link_libraries(excelreader_cpp_sheets PRIVATE xl::excelreader Threads::Threads) + +add_test(NAME excelreader_cpp_sheets COMMAND excelreader_cpp_sheets) +excelreader_copy_native_library(excelreader_cpp_sheets) + add_executable(excelreader_cpp_batch batch.cpp) target_link_libraries(excelreader_cpp_batch PRIVATE xl::excelreader) diff --git a/cpp/tests/arrow.cpp b/cpp/tests/arrow.cpp index cd561350..f42ca3c8 100644 --- a/cpp/tests/arrow.cpp +++ b/cpp/tests/arrow.cpp @@ -38,7 +38,7 @@ int main() auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(workbook.has_value(), "xl::Workbook::open must succeed on the RealExcel.xlsb fixture"); - auto table = xl::parse_arrow(*workbook); + auto table = xl::parse_arrow(workbook->sheet(0)); CHECK(table.has_value(), "xl::parse_arrow must succeed"); CHECK(std::strcmp(table->schema.format, "+s") == 0, "top level must be a struct array"); @@ -55,14 +55,14 @@ int main() CHECK(table->array.release == nullptr, "moved-from table must be released/inert"); } - auto failed = xl::parse_arrow(*workbook, 1'000'000); + auto failed = xl::parse_arrow(workbook->sheet(0), 1'000'000); CHECK(!failed.has_value(), "xl::parse_arrow must fail for an out-of-range header_row"); { auto stream_workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(stream_workbook.has_value(), "opening a workbook for the Arrow stream must succeed"); - auto stream = xl::arrow_stream(*stream_workbook, 1, 8); + auto stream = xl::arrow_stream(stream_workbook->sheet(0), 1, 8); CHECK(stream.has_value(), "xl::arrow_stream must succeed"); auto schema = stream->schema(); @@ -88,8 +88,8 @@ int main() CHECK(rows == 100, "the stream must deliver all 100 data rows"); CHECK(batches == 13, "100 rows at batch size 8 is 13 batches"); - auto second = xl::arrow_stream(*stream_workbook, 1, 8); - CHECK(!second.has_value(), "a second stream on one workbook must be rejected"); + auto second = xl::arrow_stream(stream_workbook->sheet(0), 1, 8); + CHECK(second.has_value(), "a second stream on one workbook opens"); } { @@ -98,7 +98,7 @@ int main() xl::ArrowArrayGuard kept; { - auto stream = xl::arrow_stream(*abandoned_workbook, 1, 4); + auto stream = xl::arrow_stream(abandoned_workbook->sheet(0), 1, 4); CHECK(stream.has_value(), "the abandoned stream must open"); auto batch = stream->next(); CHECK(batch.has_value() && batch->has_value(), "one batch must read before abandonment"); @@ -113,7 +113,7 @@ int main() { auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(wb.has_value(), "opening a workbook for the batch_size=0 stream must succeed"); - auto stream = xl::arrow_stream(*wb, 1, 0); + auto stream = xl::arrow_stream(wb->sheet(0), 1, 0); CHECK(stream.has_value(), "xl::arrow_stream with batch_size=0 must succeed"); auto batch = stream->next(); CHECK(batch.has_value() && batch->has_value(), "batch_size=0 must yield one batch"); @@ -125,7 +125,7 @@ int main() { auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(wb.has_value(), "opening a workbook for the negative-batch-size stream must succeed"); - auto stream = xl::arrow_stream(*wb, 1, -1); + auto stream = xl::arrow_stream(wb->sheet(0), 1, -1); CHECK(!stream.has_value(), "a negative batch size must be rejected"); if (!stream.has_value()) { @@ -136,12 +136,12 @@ int main() { auto whole_wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(whole_wb.has_value(), "opening a workbook for the whole-sheet comparison must succeed"); - auto whole = xl::parse_arrow(*whole_wb); + auto whole = xl::parse_arrow(whole_wb->sheet(0)); CHECK(whole.has_value(), "the whole-sheet parse_arrow must succeed for comparison"); auto stream_wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(stream_wb.has_value(), "opening a workbook for the streamed comparison must succeed"); - auto stream = xl::arrow_stream(*stream_wb, 1, 4); + auto stream = xl::arrow_stream(stream_wb->sheet(0), 1, 4); CHECK(stream.has_value(), "xl::arrow_stream at batch_size=4 must succeed"); auto first = stream->next(); @@ -179,39 +179,33 @@ int main() { auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(wb.has_value(), "opening a workbook for the foreign-read test must succeed"); - auto stream = xl::arrow_stream(*wb, 1, 4); + auto stream = xl::arrow_stream(wb->sheet(0), 1, 4); CHECK(stream.has_value(), "the stream opens before the foreign read"); auto first = stream->next(); CHECK(first.has_value() && first->has_value(), "the first batch reads before the foreign read"); - auto stolen = xl::parse_sheet(*wb); + auto stolen = xl::parse_sheet(wb->sheet(0)); CHECK(stolen.has_value(), "the foreign whole-sheet read succeeds"); auto after = stream->next(); - CHECK(!after.has_value(), "the invalidated stream fails"); - auto again = stream->next(); - CHECK(!again.has_value(), "the failure latches on every later call"); - if (!after.has_value() && !again.has_value()) - { - CHECK(after.error().message == again.error().message, "the latched message is stable"); - } + CHECK(after.has_value() && after->has_value(), "the stream keeps reading after a whole-sheet read"); } { auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(wb.has_value(), "opening a workbook for the reader-then-stream test must succeed"); - auto reader = xl::typed_reader(*wb, 1, 8); + auto reader = xl::typed_reader(wb->sheet(0), 1, 8); CHECK(reader.has_value(), "the typed reader opens first"); - auto stream = xl::arrow_stream(*wb, 1, 8); - CHECK(!stream.has_value(), "an Arrow stream must be rejected while a typed reader is live"); + auto stream = xl::arrow_stream(wb->sheet(0), 1, 8); + CHECK(stream.has_value(), "an Arrow stream opens while a typed reader is live"); } { auto wb = xl::Workbook::open(EXCELREADER_FIXTURE_PATH, XL_FORMAT_XLSB, &options); CHECK(wb.has_value(), "opening a workbook for the stream-then-reader test must succeed"); - auto stream = xl::arrow_stream(*wb, 1, 8); + auto stream = xl::arrow_stream(wb->sheet(0), 1, 8); CHECK(stream.has_value(), "the Arrow stream opens first"); - auto reader = xl::typed_reader(*wb, 1, 8); - CHECK(!reader.has_value(), "a typed reader must be rejected while an Arrow stream is live"); + auto reader = xl::typed_reader(wb->sheet(0), 1, 8); + CHECK(reader.has_value(), "a typed reader opens while an Arrow stream is live"); } std::printf("OK: C++ arrow test passed\n"); diff --git a/cpp/tests/batch.cpp b/cpp/tests/batch.cpp index 39dfcc10..d8cc1b2c 100644 --- a/cpp/tests/batch.cpp +++ b/cpp/tests/batch.cpp @@ -47,7 +47,7 @@ namespace { return {}; } - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); check(table.has_value(), "parse_sheet succeeds"); if (!table.has_value()) { @@ -74,7 +74,7 @@ namespace { return; } - auto reader = xl::typed_reader(*workbook, 1, batch_size); + auto reader = xl::typed_reader(workbook->sheet(0), 1, batch_size); check(reader.has_value(), "typed_reader opens"); if (!reader.has_value()) { @@ -117,7 +117,7 @@ namespace { return; } - auto reader = xl::typed_reader(*workbook, 1, 8); + auto reader = xl::typed_reader(workbook->sheet(0), 1, 8); check(reader.has_value(), "typed_reader opens for the range-for read"); if (!reader.has_value()) { @@ -137,7 +137,7 @@ namespace check(seen == static_cast(expected.size()), "range-for sees every row"); } - void test_second_reader_is_rejected() + void test_second_reader_is_allowed() { auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH); check(workbook.has_value(), "open the fixture for the second-reader test"); @@ -145,15 +145,10 @@ namespace { return; } - auto first = xl::typed_reader(*workbook, 1, 8); + auto first = xl::typed_reader(workbook->sheet(0), 1, 8); check(first.has_value(), "the first reader opens"); - auto second = xl::typed_reader(*workbook, 1, 8); - check(!second.has_value(), "a second reader on the same workbook is rejected"); - if (!second.has_value()) - { - check(second.error().code == XL_ERROR, "the rejection is XL_ERROR"); - check(!second.error().message.empty(), "the rejection carries a message"); - } + auto second = xl::typed_reader(workbook->sheet(0), 1, 8); + check(second.has_value(), "a second reader on the same workbook opens"); } void test_negative_batch_size_is_rejected() @@ -164,7 +159,7 @@ namespace { return; } - auto reader = xl::typed_reader(*workbook, 1, -1); + auto reader = xl::typed_reader(workbook->sheet(0), 1, -1); check(!reader.has_value(), "a negative batch size is rejected"); if (!reader.has_value()) { @@ -172,15 +167,15 @@ namespace } } - void test_foreign_read_latches_the_error() + void test_foreign_read_leaves_the_reader_alone() { auto workbook = xl::Workbook::open(EXCELREADER_FIXTURE_PATH); - check(workbook.has_value(), "open the fixture for the invalidation test"); + check(workbook.has_value(), "open the fixture for the foreign-read test"); if (!workbook.has_value()) { return; } - auto reader = xl::typed_reader(*workbook, 1, 4); + auto reader = xl::typed_reader(workbook->sheet(0), 1, 4); check(reader.has_value(), "the reader opens before the foreign read"); if (!reader.has_value()) { @@ -189,17 +184,11 @@ namespace auto first = reader->next(); check(first.has_value() && first->has_value(), "the first batch reads before the foreign read"); - auto stolen = xl::parse_sheet(*workbook); + auto stolen = xl::parse_sheet(workbook->sheet(0)); check(stolen.has_value(), "the foreign whole-sheet read succeeds"); auto after = reader->next(); - check(!after.has_value(), "the invalidated reader fails"); - auto again = reader->next(); - check(!again.has_value(), "the failure latches on every later call"); - if (!after.has_value() && !again.has_value()) - { - check(after.error().message == again.error().message, "the latched message is stable"); - } + check(after.has_value() && after->has_value(), "the reader keeps reading after a whole-sheet read"); } } @@ -209,9 +198,9 @@ int main() check(expected.size() == 100, "RealExcel.xlsb has 100 data rows"); test_batches_equal_whole_sheet(expected); test_range_for_matches_next(expected); - test_second_reader_is_rejected(); + test_second_reader_is_allowed(); test_negative_batch_size_is_rejected(); - test_foreign_read_latches_the_error(); + test_foreign_read_leaves_the_reader_alone(); if (failures != 0) { diff --git a/cpp/tests/rows.cpp b/cpp/tests/rows.cpp index aa0fd1cf..af692b2e 100644 --- a/cpp/tests/rows.cpp +++ b/cpp/tests/rows.cpp @@ -37,10 +37,15 @@ namespace } std::vector> all; - auto cursor = workbook->rows(); + auto cursor = workbook->sheet(0).rows(); + check(cursor.has_value(), "open a cursor"); + if (!cursor.has_value()) + { + return {}; + } while (true) { - auto row = cursor.next_row(); + auto row = cursor->next_row(); if (!row.has_value()) { check(row.error().code == XL_EOF, "the cursor stops on XL_EOF, not a real error"); @@ -85,11 +90,16 @@ int main() check(workbook.has_value(), "open the oversized CSV"); if (workbook.has_value()) { - auto cursor = workbook->rows(); - auto header = cursor.next_row(); + auto cursor = workbook->sheet(0).rows(); + check(cursor.has_value(), "open a cursor on the oversized CSV"); + if (!cursor.has_value()) + { + return 1; + } + auto header = cursor->next_row(); check(header.has_value() && (*header)[0].value == "a", "header survives"); - auto big = cursor.next_row(); + auto big = cursor->next_row(); check(big.has_value(), "the oversized row is returned, not lost"); if (big.has_value()) { @@ -106,7 +116,7 @@ int main() check(workbook.has_value(), "open for read_all_decoded"); if (workbook.has_value()) { - auto decoded = workbook->read_all_decoded(); + auto decoded = workbook->sheet(0).read_all_decoded(); check(decoded.has_value(), "read_all_decoded succeeds"); if (decoded.has_value()) { @@ -133,13 +143,14 @@ int main() auto workbook = xl::Workbook::open(fixture()); if (workbook.has_value()) { - auto cursor = workbook->rows(); - while (cursor.next_row().has_value()) + auto cursor = workbook->sheet(0).rows(); + check(cursor.has_value(), "open a cursor to drain"); + while (cursor.has_value() && cursor->next_row().has_value()) { } - auto decoded = workbook->read_all_decoded(); - check(decoded.has_value(), "an empty remainder is not an error"); - check(decoded.has_value() && decoded->empty(), "and it is empty"); + auto decoded = workbook->sheet(0).read_all_decoded(); + check(decoded.has_value(), "a read after another cursor drained is not an error"); + check(decoded.has_value() && !decoded->empty(), "and it still reads every row, from the start"); } } @@ -147,7 +158,7 @@ int main() auto workbook = xl::Workbook::open(fixture()); if (workbook.has_value()) { - auto decoded = workbook->read_all_decoded(); + auto decoded = workbook->sheet(0).read_all_decoded(); check(decoded.has_value(), "open a set to move"); if (decoded.has_value()) { diff --git a/cpp/tests/sheets.cpp b/cpp/tests/sheets.cpp new file mode 100644 index 00000000..a9e506f4 --- /dev/null +++ b/cpp/tests/sheets.cpp @@ -0,0 +1,221 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + int failures = 0; + + void check(bool condition, const char *what) + { + if (!condition) + { + std::fprintf(stderr, "FAILED: %s\n", what); + ++failures; + } + } + + std::vector build(int sheet_count, int row_count) + { + auto writer = xl::WriterHandle::open_memory(XL_FORMAT_XLSX); + check(writer.has_value(), "open the in-memory writer"); + if (!writer.has_value()) + { + return {}; + } + for (int s = 0; s < sheet_count; ++s) + { + const std::string name = "sheet" + std::to_string(s); + check(writer->start_sheet(name).has_value(), "start a sheet"); + for (int r = 0; r < row_count; ++r) + { + const std::string value = "s" + std::to_string(s) + "-r" + std::to_string(r); + check(writer->start_row().has_value(), "start a row"); + check(writer->write(value).has_value(), "write a cell"); + check(writer->end_row().has_value(), "end a row"); + } + check(writer->end_sheet().has_value(), "end a sheet"); + } + auto bytes = writer->bytes(); + check(bytes.has_value(), "read the written bytes"); + return bytes.has_value() ? std::move(*bytes) : std::vector{}; + } + + std::vector drain(xl::RowCursor &cursor) + { + std::vector values; + while (true) + { + auto row = cursor.next_row(); + if (!row.has_value()) + { + check(row.error().code == XL_EOF, "the cursor stops on XL_EOF, not a real error"); + return values; + } + values.emplace_back((*row)[0].value); + } + } + + std::vector read_sheet(xl::Sheet sheet) + { + auto cursor = sheet.rows(); + check(cursor.has_value(), "open a cursor"); + return cursor.has_value() ? drain(*cursor) : std::vector{}; + } +} + +int main() +{ + const std::vector two = build(2, 3); + + { + auto workbook = xl::Workbook::open_memory(two); + check(workbook.has_value(), "open the two-sheet workbook"); + if (workbook.has_value()) + { + const auto second = read_sheet(workbook->sheet(1)); + check(second.size() == 3 && second.front() == "s1-r0", "sheet 1 reads its own rows"); + const auto first = read_sheet(workbook->sheet(0)); + check(first.size() == 3 && first.front() == "s0-r0", "sheet 0 reads its own rows"); + + auto sheets = workbook->sheets(); + check(sheets.has_value() && sheets->size() == 2, "sheets() lists both sheets"); + if (sheets.has_value() && sheets->size() == 2) + { + check((*sheets)[1].index() == 1, "a sheet knows its index"); + auto name = (*sheets)[1].name(); + check(name.has_value() && *name == "sheet1", "a sheet knows its name"); + auto visibility = (*sheets)[0].visibility(); + check(visibility.has_value() && *visibility == xl::SheetVisibility::Visible, "a written sheet is visible"); + } + + auto exact = workbook->sheet_by_name("sheet1"); + check(exact.has_value() && exact->has_value() && (*exact)->index() == 1, "sheet_by_name finds an exact name"); + auto other_case = workbook->sheet_by_name("SHEET1"); + check(other_case.has_value() && other_case->has_value() && (*other_case)->index() == 1, "sheet_by_name ignores case"); + auto missing = workbook->sheet_by_name("nope"); + check(missing.has_value() && !missing->has_value(), "a missing name is an empty optional, not an error"); + + auto past_end = workbook->sheet(2).rows(); + check(!past_end.has_value() && past_end.error().code == XL_ERROR, "a sheet index past the end fails at the first read"); + auto negative = workbook->sheet(-1).rows(); + check(!negative.has_value() && negative.error().code == XL_INVALID_ARGUMENT, "a negative sheet index is an invalid argument"); + + auto decoded = workbook->sheet(1).read_all_decoded(); + check(decoded.has_value() && decoded->size() == 3, "read_all_decoded reads the named sheet"); + auto schema = workbook->sheet(1).infer_schema(0, 10); + check(schema.has_value() && schema->size() == 1, "infer_schema samples the named sheet"); + } + } + + { + auto workbook = xl::Workbook::open_memory(two); + check(workbook.has_value(), "open for two cursors"); + if (workbook.has_value()) + { + auto one = workbook->sheet(1).rows(); + auto another = workbook->sheet(1).rows(); + check(one.has_value() && another.has_value(), "two cursors open on one sheet"); + if (one.has_value() && another.has_value()) + { + check(one->next_row().has_value(), "the first cursor advances"); + check(another->next_row().has_value(), "the second cursor advances on its own"); + check(drain(*one).size() == 2, "the first cursor has two rows left"); + check(drain(*another).size() == 2, "the second cursor has two rows left"); + } + } + } + + { + auto workbook = xl::Workbook::open_memory(two); + check(workbook.has_value(), "open for the moved cursor"); + if (workbook.has_value()) + { + auto cursor = workbook->sheet(0).rows(); + check(cursor.has_value(), "open a cursor to move"); + if (cursor.has_value()) + { + xl::RowCursor moved = std::move(*cursor); + check(cursor->handle() == nullptr, "a moved-from cursor holds nothing"); + check(drain(moved).size() == 3, "the moved-to cursor reads every row"); + } + check(read_sheet(workbook->sheet(0)).size() == 3, "the workbook still reads after the cursors are gone"); + } + } + + { + std::optional survivor; + std::optional stale; + { + auto workbook = xl::Workbook::open_memory(two); + check(workbook.has_value(), "open for the outliving cursor"); + if (workbook.has_value()) + { + xl::Workbook relocated = std::move(*workbook); + xl::Sheet sheet = relocated.sheet(1); + check(read_sheet(sheet).size() == 3, "a sheet taken after the workbook moved still reads"); + auto cursor = sheet.rows(); + check(cursor.has_value(), "open a cursor before the workbook closes"); + if (cursor.has_value()) + { + survivor.emplace(std::move(*cursor)); + } + stale = sheet; + } + } + check(survivor.has_value() && drain(*survivor).size() == 3, "a cursor reads every row after its workbook closed"); + if (stale.has_value()) + { + auto after_close = stale->rows(); + check(!after_close.has_value() && after_close.error().code == XL_INVALID_HANDLE, "a sheet of a closed workbook reports an invalid handle"); + } + } + + { + constexpr int sheet_count = 6; + constexpr int row_count = 500; + const std::vector many = build(sheet_count, row_count); + auto workbook = xl::Workbook::open_memory(many); + check(workbook.has_value(), "open the many-sheet workbook"); + if (workbook.has_value()) + { + std::vector> sequential; + for (int s = 0; s < sheet_count; ++s) + { + sequential.push_back(read_sheet(workbook->sheet(s))); + } + check(sequential[3].size() == static_cast(row_count) && sequential[3].front() == "s3-r0", "the sequential read sees sheet 3"); + + std::vector> parallel(static_cast(sheet_count) * 2); + { + std::vector threads; + for (size_t i = 0; i < parallel.size(); ++i) + { + threads.emplace_back([¶llel, &workbook, i] + { parallel[i] = read_sheet(workbook->sheet(static_cast(i % sheet_count))); }); + } + } + bool same = true; + for (size_t i = 0; i < parallel.size(); ++i) + { + same = same && parallel[i] == sequential[i % sheet_count]; + } + check(same, "every sheet read on its own thread matches the sequential read"); + } + } + + if (failures != 0) + { + std::fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + std::puts("OK: sheets"); + return 0; +} diff --git a/cpp/tests/smoke.cpp b/cpp/tests/smoke.cpp index e3ac5e5e..4dd245ee 100644 --- a/cpp/tests/smoke.cpp +++ b/cpp/tests/smoke.cpp @@ -53,7 +53,7 @@ struct xl::ExcelMapper static int test_parse(xl::Workbook &workbook) { - auto table = xl::parse_sheet(workbook); + auto table = xl::parse_sheet(workbook.sheet(0)); CHECK(table.has_value(), "xl::parse_sheet must succeed"); CHECK(table->size() == 100, "RealExcel.xlsb has 100 data rows"); @@ -83,12 +83,9 @@ static int test_sheets(xl::Workbook &workbook) CHECK(names.has_value(), "sheet_names must succeed"); CHECK(names->size() == static_cast(*count), "sheet_names must return one name per sheet"); - auto current = workbook.sheet_name(); - CHECK(current.has_value(), "sheet_name must succeed"); - CHECK(*current == names->front(), "the first sheet is selected before any move_to_sheet"); - - CHECK(workbook.move_to_sheet(0).has_value(), "move_to_sheet(0) must succeed"); - CHECK(!workbook.move_to_sheet(*count).has_value(), "an index past the last sheet must fail"); + auto first = workbook.sheet_name_at(0); + CHECK(first.has_value(), "sheet_name_at(0) must succeed"); + CHECK(*first == names->front(), "sheet_name_at(0) must be the first of sheet_names"); CHECK(workbook.is_date1904().has_value(), "is_date1904 must succeed"); return 0; @@ -96,7 +93,7 @@ static int test_sheets(xl::Workbook &workbook) static int test_infer_schema(const xl::Workbook &workbook) { - auto schema = workbook.infer_schema(1, 100); + auto schema = workbook.sheet(0).infer_schema(1, 100); CHECK(schema.has_value(), "infer_schema must succeed"); CHECK(!schema->empty(), "the fixture has columns to infer"); CHECK(schema->front().name.has_value(), "the first column must carry a header name"); @@ -107,7 +104,7 @@ static int test_infer_schema(const xl::Workbook &workbook) static int test_parse_with_alias(xl::Workbook &workbook) { - auto table = xl::parse_sheet(workbook); + auto table = xl::parse_sheet(workbook.sheet(0)); CHECK(table.has_value(), "xl::parse_sheet must succeed by resolving the second candidate name"); CHECK(table->size() == 100, "RealExcel.xlsb has 100 data rows"); AliasRow first = *table->begin(); diff --git a/cpp/tests/write.cpp b/cpp/tests/write.cpp index 06bc37fd..b4809485 100644 --- a/cpp/tests/write.cpp +++ b/cpp/tests/write.cpp @@ -74,7 +74,7 @@ static int test_write_columns_round_trip() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the written file must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 2, "two rows were written"); @@ -125,7 +125,7 @@ static int test_encrypt_package_round_trip() options.password("hunter2"); auto workbook = xl::Workbook::open(encrypted_path.string(), XL_FORMAT_AUTO, &options); CHECK(workbook.has_value(), "the encrypted file must open with the right password"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the decrypted file must parse back"); CHECK(table->size() == 2, "two rows were written"); WrittenRow first = *table->begin(); @@ -181,7 +181,7 @@ static int test_write_columns_to_memory_round_trip() auto workbook = xl::Workbook::open_memory(*bytes, XL_FORMAT_XLSX); CHECK(workbook.has_value(), "the returned bytes must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the returned bytes must parse back"); CHECK(table->size() == 2, "two rows were written"); @@ -283,7 +283,7 @@ static int test_bool_round_trip() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the written file must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 3, "three rows were written"); @@ -321,7 +321,7 @@ static int test_optional_round_trip() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the written file must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 3, "three rows were written"); @@ -381,7 +381,7 @@ static int test_write_sheet_round_trip() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the written file must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 2, "two rows were written"); @@ -425,7 +425,7 @@ static int test_write_sheet_to_memory_round_trip() auto workbook = xl::Workbook::open_memory(*bytes, XL_FORMAT_XLSX); CHECK(workbook.has_value(), "the returned bytes must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the returned bytes must parse back"); CHECK(table->size() == 2, "two rows were written"); @@ -451,7 +451,7 @@ static int test_write_sheet_options_and_csv() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the written file must open"); - auto name = workbook->sheet_name(); + auto name = workbook->sheet_name_at(0); CHECK(name.has_value(), "the sheet name must be readable"); CHECK(*name == "Dados", "WriteOptions::sheet_name must reach the file"); } @@ -509,9 +509,9 @@ static int test_writer_handle() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the file xl_close_write_handle produced must open"); - auto name = workbook->sheet_name(); + auto name = workbook->sheet_name_at(0); CHECK(name.has_value() && *name == "Planilha1", "xl_start_sheet's name must reach the file"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 1, "exactly one data row was written"); WrittenRow first = *table->begin(); @@ -609,7 +609,7 @@ static int test_writer_handle_class_round_trip() { auto workbook = xl::Workbook::open(path.string()); CHECK(workbook.has_value(), "the file WriterHandle produced must open"); - auto table = xl::parse_sheet(*workbook); + auto table = xl::parse_sheet(workbook->sheet(0)); CHECK(table.has_value(), "the written file must parse back"); CHECK(table->size() == 2, "two data rows were written"); @@ -647,7 +647,7 @@ static int test_writer_handle_class_to_memory_round_trip() auto workbook = xl::Workbook::open_memory(*bytes, XL_FORMAT_XLSX); CHECK(workbook.has_value(), "the bytes bytes() returned must open"); - auto sheet_name = workbook->sheet_name(); + auto sheet_name = workbook->sheet_name_at(0); CHECK(sheet_name.has_value() && *sheet_name == "Dados", "start_sheet's name must reach the bytes"); auto bytes_again = handle->bytes(); diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index 5963919a..c9077c1c 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -532,22 +532,35 @@ namespace xl size_t count_{}; }; + /// Reads one sheet row by row. Owns a native cursor and closes it on destruction. Use one + /// cursor from one thread at a time; any number may be open on one workbook. class RowCursor { public: - explicit RowCursor(xl_workbook *handle) : handle_(handle) {} + explicit RowCursor(xl_rows *rows) noexcept : rows_(rows) {} RowCursor(const RowCursor &) = delete; RowCursor &operator=(const RowCursor &) = delete; - RowCursor(RowCursor &&) noexcept = default; - RowCursor &operator=(RowCursor &&) noexcept = default; - /// The returned view points into memory the workbook owns and is valid until the next - /// next_row() on this workbook or until the workbook closes. + RowCursor(RowCursor &&other) noexcept : rows_(std::exchange(other.rows_, nullptr)) {} + RowCursor &operator=(RowCursor &&other) noexcept + { + if (this != &other) + { + close(); + rows_ = std::exchange(other.rows_, nullptr); + } + return *this; + } + + ~RowCursor() { close(); } + + /// The returned view points into memory the cursor owns and is valid until the next + /// next_row() on this cursor or until the cursor is destroyed. std::expected next_row() { xl_row row{}; - const int32_t status = xl_next_row_view(handle_, &row); + const int32_t status = xl_rows_next_view(rows_, &row); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); @@ -559,25 +572,36 @@ namespace xl return RowView(row.cells, static_cast(row.cell_count)); } + xl_rows *handle() const noexcept { return rows_; } + private: - xl_workbook *handle_{}; + void close() noexcept + { + if (rows_ != nullptr) + { + xl_rows_close(rows_); + rows_ = nullptr; + } + } + + xl_rows *rows_ = nullptr; }; class DecodedRows { public: - explicit DecodedRows(xl_rows raw) : raw_(raw) {} + explicit DecodedRows(xl_rows_decoded raw) : raw_(raw) {} DecodedRows(const DecodedRows &) = delete; DecodedRows &operator=(const DecodedRows &) = delete; - DecodedRows(DecodedRows &&other) noexcept : raw_(std::exchange(other.raw_, xl_rows{})) {} + DecodedRows(DecodedRows &&other) noexcept : raw_(std::exchange(other.raw_, xl_rows_decoded{})) {} DecodedRows &operator=(DecodedRows &&other) noexcept { if (this != &other) { release(); - raw_ = std::exchange(other.raw_, xl_rows{}); + raw_ = std::exchange(other.raw_, xl_rows_decoded{}); } return *this; } @@ -632,13 +656,120 @@ namespace xl { xl_free_rows(&raw_); } - raw_ = xl_rows{}; + raw_ = xl_rows_decoded{}; } - xl_rows raw_{}; + xl_rows_decoded raw_{}; }; + enum class SheetVisibility : int32_t + { + Visible = XL_SHEET_VISIBLE, + Hidden = XL_SHEET_HIDDEN, + VeryHidden = XL_SHEET_VERY_HIDDEN, + }; + + /// One sheet of a Workbook: the native workbook handle and an index. Holds no resource of its + /// own, so it is cheap to copy and may be handed to another thread; each read opens what it + /// needs. It must not be used after its workbook is destroyed. + class Sheet + { + public: + Sheet(xl_workbook *handle, int32_t index) noexcept : handle_(handle), index_(index) {} + + int32_t index() const noexcept { return index_; } + + std::expected name() const + { + return detail::fill_string([this](uint8_t *buffer, int32_t capacity, int32_t *out_len) + { return xl_sheet_name_at(handle_, index_, buffer, capacity, out_len); }); + } + + std::expected visibility() const + { + int32_t raw = 0; + int32_t status = xl_sheet_visibility_at(handle_, index_, &raw); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return static_cast(raw); + } + + /// A cursor over this sheet, from its first row. Each call opens an independent cursor. + std::expected rows() const + { + xl_rows *rows = nullptr; + int32_t status = xl_rows_open(handle_, index_, &rows); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return RowCursor(rows); + } + + std::expected read_all_decoded() const + { + auto cursor = rows(); + if (!cursor.has_value()) + { + return std::unexpected(cursor.error()); + } + xl_rows_decoded raw{}; + const int32_t status = xl_rows_read_all_decoded(cursor->handle(), &raw); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return DecodedRows(raw); + } + + /// flags: 0, or XL_INFER_PARSE_TEXT to also type cells that hold text. + std::expected, Error> infer_schema(int32_t header_row = 1, + int32_t sample_size = 100, + int32_t flags = 0) const + { + xl_inferred_schema schema{}; + int32_t status = xl_infer_schema(handle_, index_, header_row, sample_size, flags, &schema); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + + struct SchemaGuard + { + xl_inferred_schema *schema; + ~SchemaGuard() { xl_free_schema(schema); } + } guard{&schema}; + + std::vector columns; + columns.reserve(static_cast(schema.column_count > 0 ? schema.column_count : 0)); + for (int32_t i = 0; i < schema.column_count; ++i) + { + const xl_column_spec &spec = schema.columns[i]; + InferredColumn column{}; + if (spec.name_count > 0 && spec.names[0] != nullptr && spec.name_lens[0] > 0) + { + column.name = std::string(reinterpret_cast(spec.names[0]), + static_cast(spec.name_lens[0])); + } + column.index = spec.index; + column.type = spec.type; + column.nullable = spec.nullable != 0; + columns.push_back(std::move(column)); + } + return columns; + } + + xl_workbook *handle() const noexcept { return handle_; } + + private: + xl_workbook *handle_ = nullptr; + int32_t index_ = 0; + }; + + /// An open workbook. Safe to use from several threads at once: every read goes through a Sheet and opens its own cursor. class Workbook { public: @@ -668,9 +799,9 @@ namespace xl xl_workbook *handle = nullptr; xl_open_options c_options{}; const xl_open_options *c_options_ptr = detail::lower_options(options, c_options); - int32_t status = xl_open_file_ex(reinterpret_cast(path.data()), - static_cast(path.size()), format, - c_options_ptr, &handle); + int32_t status = xl_open_file(reinterpret_cast(path.data()), + static_cast(path.size()), format, + c_options_ptr, &handle); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); @@ -688,8 +819,8 @@ namespace xl xl_workbook *handle = nullptr; xl_open_options c_options{}; const xl_open_options *c_options_ptr = detail::lower_options(options, c_options); - int32_t status = xl_open_memory_ex(data.data(), static_cast(data.size()), format, - c_options_ptr, &handle); + int32_t status = xl_open_memory(data.data(), static_cast(data.size()), format, + c_options_ptr, &handle); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); @@ -709,12 +840,6 @@ namespace xl return count; } - std::expected sheet_name() const - { - return detail::fill_string([this](uint8_t *buffer, int32_t capacity, int32_t *out_len) - { return xl_sheet_name(handle_, buffer, capacity, out_len); }); - } - std::expected sheet_name_at(int32_t index) const { return detail::fill_string([this, index](uint8_t *buffer, int32_t capacity, int32_t *out_len) @@ -742,74 +867,53 @@ namespace xl return names; } - std::expected move_to_sheet(int32_t index) + /// The sheet at `index`. Makes no native call: an index outside the workbook is reported + /// by the first read on the returned sheet. + Sheet sheet(int32_t index) const noexcept { return Sheet(handle_, index); } + + /// Every sheet, in workbook order. Opens nothing. + std::expected, Error> sheets() const { - int32_t status = xl_move_to_sheet(handle_, index); - if (status != XL_OK) + auto count = sheet_count(); + if (!count.has_value()) { - return std::unexpected(detail::make_error(status)); + return std::unexpected(count.error()); } - return {}; - } - - std::expected is_date1904() const - { - int32_t flag = 0; - int32_t status = xl_is_date1904(handle_, &flag); - if (status != XL_OK) + std::vector result; + result.reserve(static_cast(*count > 0 ? *count : 0)); + for (int32_t i = 0; i < *count; ++i) { - return std::unexpected(detail::make_error(status)); + result.emplace_back(handle_, i); } - return flag != 0; + return result; } - RowCursor rows() { return RowCursor(handle_); } - - std::expected read_all_decoded() + /// The sheet called `name`, compared without regard to case; empty when no sheet matches. + std::expected, Error> sheet_by_name(std::string_view name) const { - xl_rows raw{}; - const int32_t status = xl_read_all_decoded(handle_, &raw); + int32_t index = -1; + int32_t status = xl_sheet_index(handle_, reinterpret_cast(name.data()), + static_cast(name.size()), &index); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); } - return DecodedRows(raw); + if (index < 0) + { + return std::optional{}; + } + return std::optional{Sheet(handle_, index)}; } - - std::expected, Error> infer_schema(int32_t header_row = 1, - int32_t sample_size = 100) const + std::expected is_date1904() const { - xl_inferred_schema schema{}; - int32_t status = xl_infer_schema(handle_, header_row, sample_size, &schema); + int32_t flag = 0; + int32_t status = xl_is_date1904(handle_, &flag); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); } - - struct SchemaGuard - { - xl_inferred_schema *schema; - ~SchemaGuard() { xl_free_schema(schema); } - } guard{&schema}; - - std::vector columns; - columns.reserve(static_cast(schema.column_count > 0 ? schema.column_count : 0)); - for (int32_t i = 0; i < schema.column_count; ++i) - { - const xl_column_spec &spec = schema.columns[i]; - InferredColumn column{}; - if (spec.name_count > 0 && spec.names[0] != nullptr && spec.name_lens[0] > 0) - { - column.name = std::string(reinterpret_cast(spec.names[0]), - static_cast(spec.name_lens[0])); - } - column.index = spec.index; - column.type = spec.type; - column.nullable = spec.nullable != 0; - columns.push_back(std::move(column)); - } - return columns; + return flag != 0; } xl_workbook *handle() const noexcept { return handle_; } @@ -1224,7 +1328,8 @@ namespace xl template - std::expected, Error> parse_sheet(Workbook &workbook, int32_t header_row = 1) + std::expected, Error> parse_sheet(Sheet sheet, int32_t header_row = 1, + int32_t degree_of_parallelism = 1) { static constexpr auto bindings = ExcelMapper::get_bindings(); static constexpr size_t num_fields = std::tuple_size_v; @@ -1234,7 +1339,9 @@ namespace xl std::span specs(specs_array); xl_table table{}; - int32_t status = xl_parse_typed(workbook.handle(), specs.data(), static_cast(specs.size()), header_row, &table); + int32_t status = xl_parse_typed(sheet.handle(), sheet.index(), specs.data(), + static_cast(specs.size()), header_row, + degree_of_parallelism, &table); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); @@ -1349,7 +1456,7 @@ namespace xl }; template - std::expected, Error> typed_reader(Workbook &workbook, int32_t header_row = 1, + std::expected, Error> typed_reader(Sheet sheet, int32_t header_row = 1, int64_t batch_size = 10000) { static constexpr auto bindings = ExcelMapper::get_bindings(); @@ -1359,7 +1466,7 @@ namespace xl detail::build_specs(bindings, std::make_index_sequence{}, name_lens_storage); xl_typed_reader *reader = nullptr; - int32_t status = xl_typed_reader_open(workbook.handle(), specs_array.data(), + int32_t status = xl_typed_reader_open(sheet.handle(), sheet.index(), specs_array.data(), static_cast(specs_array.size()), header_row, batch_size, &reader); if (status != XL_OK) diff --git a/src/ExcelReader.Native/include/excelreader_arrow.hpp b/src/ExcelReader.Native/include/excelreader_arrow.hpp index af5e6b1d..e3017143 100644 --- a/src/ExcelReader.Native/include/excelreader_arrow.hpp +++ b/src/ExcelReader.Native/include/excelreader_arrow.hpp @@ -59,7 +59,8 @@ namespace xl }; template - std::expected parse_arrow(Workbook &workbook, int32_t header_row = 1) + std::expected parse_arrow(Sheet sheet, int32_t header_row = 1, + int32_t degree_of_parallelism = 1) { static constexpr auto bindings = ExcelMapper::get_bindings(); static constexpr size_t num_fields = std::tuple_size_v; @@ -69,9 +70,9 @@ namespace xl std::span specs(specs_array); ArrowTable result; - int32_t status = xl_parse_arrow(workbook.handle(), specs.data(), + int32_t status = xl_parse_arrow(sheet.handle(), sheet.index(), specs.data(), static_cast(specs.size()), header_row, - &result.array, &result.schema); + degree_of_parallelism, &result.array, &result.schema); if (status != XL_OK) { return std::unexpected(detail::make_error(status)); @@ -234,7 +235,7 @@ namespace xl }; template - std::expected arrow_stream(Workbook &workbook, int32_t header_row = 1, + std::expected arrow_stream(Sheet sheet, int32_t header_row = 1, int64_t batch_size = 10000) { static constexpr auto bindings = ExcelMapper::get_bindings(); @@ -244,7 +245,7 @@ namespace xl detail::build_specs(bindings, std::make_index_sequence{}, name_lens_storage); ArrowArrayStream stream{}; - int32_t status = xl_parse_arrow_stream(workbook.handle(), specs_array.data(), + int32_t status = xl_parse_arrow_stream(sheet.handle(), sheet.index(), specs_array.data(), static_cast(specs_array.size()), header_row, batch_size, &stream); if (status != XL_OK) From 783276086098c33bfc597aa54452b111ae40e019 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:48:57 -0300 Subject: [PATCH 29/49] feat(rust)!: read through sheets over C ABI v6 Workbook hands out Sheet values; rows(), read_all_*, typed_chunks, infer_schema, parse_sheet and the Arrow functions take a sheet and open their own native cursor. move_to_sheet and the index-less sheet_name are removed. Co-Authored-By: Claude Sonnet 5.5 --- rust/excelreader/benches/alloc_bench.rs | 12 +- rust/excelreader/benches/compare_bench.rs | 8 +- rust/excelreader/benches/parse_bench.rs | 19 +- rust/excelreader/benches/write_bench.rs | 4 +- rust/excelreader/src/arrow.rs | 28 +-- rust/excelreader/src/lib.rs | 67 ++++-- rust/excelreader/src/rows.rs | 61 +++-- rust/excelreader/src/workbook.rs | 271 ++++++++++++++-------- rust/excelreader/tests/parse_arrow.rs | 36 ++- rust/excelreader/tests/parse_typed.rs | 73 ++---- rust/excelreader/tests/rows.rs | 96 ++++---- rust/excelreader/tests/sheets.rs | 110 +++++++++ rust/excelreader/tests/typed_reader.rs | 46 ++-- rust/excelreader/tests/write_typed.rs | 30 +-- rust/excelreader/tests/writer_handle.rs | 10 +- 15 files changed, 526 insertions(+), 345 deletions(-) create mode 100644 rust/excelreader/tests/sheets.rs diff --git a/rust/excelreader/benches/alloc_bench.rs b/rust/excelreader/benches/alloc_bench.rs index 0032795b..67eb3d37 100644 --- a/rust/excelreader/benches/alloc_bench.rs +++ b/rust/excelreader/benches/alloc_bench.rs @@ -111,8 +111,8 @@ fn main() { "parse_sheet", ITERATIONS, || Workbook::open(&small_path).expect("open must succeed"), - |mut workbook| { - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + |workbook| { + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); std::hint::black_box(table); }, ); @@ -122,7 +122,7 @@ fn main() { ITERATIONS, || Workbook::open(&small_path).expect("open must succeed"), |workbook| { - let schema = workbook.infer_schema(1, 100).expect("infer_schema must succeed"); + let schema = workbook.sheet(0).expect("sheet 0").infer_schema(1, 100).expect("infer_schema must succeed"); std::hint::black_box(schema); }, ); @@ -141,8 +141,8 @@ fn main() { "parse_sheet_large", ITERATIONS_LARGE, || Workbook::open(&large_path).expect("open must succeed"), - |mut workbook| { - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + |workbook| { + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); std::hint::black_box(table); }, ); @@ -152,7 +152,7 @@ fn main() { ITERATIONS_LARGE, || Workbook::open(&large_path).expect("open must succeed"), |workbook| { - let schema = workbook.infer_schema(1, 1000).expect("infer_schema must succeed"); + let schema = workbook.sheet(0).expect("sheet 0").infer_schema(1, 1000).expect("infer_schema must succeed"); std::hint::black_box(schema); }, ); diff --git a/rust/excelreader/benches/compare_bench.rs b/rust/excelreader/benches/compare_bench.rs index f205cacc..5efb39e8 100644 --- a/rust/excelreader/benches/compare_bench.rs +++ b/rust/excelreader/benches/compare_bench.rs @@ -83,9 +83,9 @@ fn bench_excelreader(c: &mut Criterion, group: &str, path: String, format: i32) c.bench_function(group, |b| { b.iter_batched( || Workbook::open_with(&path, format, None).expect("open must succeed"), - |mut workbook| { + |workbook| { let table = - parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); let mut acc = 0i64; for row in table.iter() { acc += accumulate_full_row(&row); @@ -101,9 +101,9 @@ fn bench_excelreader_parse_only(c: &mut Criterion, group: &str, path: String, fo c.bench_function(group, |b| { b.iter_batched( || Workbook::open_with(&path, format, None).expect("open must succeed"), - |mut workbook| { + |workbook| { let table = - parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); std::hint::black_box(table.len()); std::hint::black_box(table); }, diff --git a/rust/excelreader/benches/parse_bench.rs b/rust/excelreader/benches/parse_bench.rs index a1b93a91..75f3ede1 100644 --- a/rust/excelreader/benches/parse_bench.rs +++ b/rust/excelreader/benches/parse_bench.rs @@ -52,9 +52,9 @@ fn bench_parse_sheet(c: &mut Criterion) { c.bench_function("parse_sheet", |b| { b.iter_batched( || Workbook::open(&path).expect("open must succeed"), - |mut workbook| { + |workbook| { let table = - parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); std::hint::black_box(table); }, criterion::BatchSize::SmallInput, @@ -68,8 +68,7 @@ fn bench_infer_schema(c: &mut Criterion) { b.iter_batched( || Workbook::open(&path).expect("open must succeed"), |workbook| { - let schema = workbook - .infer_schema(1, 100) + let schema = workbook.sheet(0).expect("sheet 0").infer_schema(1, 100) .expect("infer_schema must succeed"); std::hint::black_box(schema); }, @@ -93,8 +92,8 @@ fn bench_parse_sheet_large(c: &mut Criterion) { c.bench_function("parse_sheet_large", |b| { b.iter_batched( || Workbook::open(&path).expect("open must succeed"), - |mut workbook| { - let table = parse_sheet::(&mut workbook, 1) + |workbook| { + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1) .expect("parse_sheet must succeed"); std::hint::black_box(table); }, @@ -109,8 +108,7 @@ fn bench_infer_schema_large(c: &mut Criterion) { b.iter_batched( || Workbook::open(&path).expect("open must succeed"), |workbook| { - let schema = workbook - .infer_schema(1, 1000) + let schema = workbook.sheet(0).expect("sheet 0").infer_schema(1, 1000) .expect("infer_schema must succeed"); std::hint::black_box(schema); }, @@ -125,9 +123,8 @@ fn bench_typed_chunks_large(c: &mut Criterion) { c.bench_function(&format!("typed_chunks_large/{batch_size}"), |b| { b.iter_batched( || Workbook::open(&path).expect("open must succeed"), - |mut workbook| { - let chunks = workbook - .typed_chunks::(1, batch_size) + |workbook| { + let chunks = workbook.sheet(0).expect("sheet 0").typed_chunks::(1, batch_size) .expect("typed_chunks must succeed"); let mut rows = 0_i64; for batch in chunks { diff --git a/rust/excelreader/benches/write_bench.rs b/rust/excelreader/benches/write_bench.rs index 274022d2..f8c779bb 100644 --- a/rust/excelreader/benches/write_bench.rs +++ b/rust/excelreader/benches/write_bench.rs @@ -52,8 +52,8 @@ fn load_rows() -> Vec { "missing benchmark fixture {FIXTURE} - run from rust/excelreader, and check the file is \ present in the repo" ); - let mut workbook = Workbook::open(FIXTURE).expect("the fixture must open"); - let table = parse_sheet::(&mut workbook, 1).expect("the fixture must parse"); + let workbook = Workbook::open(FIXTURE).expect("the fixture must open"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the fixture must parse"); let rows: Vec = table.iter().collect(); assert!(!rows.is_empty(), "the fixture parsed to zero rows"); rows diff --git a/rust/excelreader/src/arrow.rs b/rust/excelreader/src/arrow.rs index 2effa189..2f7c2908 100644 --- a/rust/excelreader/src/arrow.rs +++ b/rust/excelreader/src/arrow.rs @@ -13,18 +13,14 @@ use arrow::ffi::{from_ffi, FFI_ArrowArray, FFI_ArrowSchema}; use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream}; use arrow::record_batch::RecordBatchReader; -use crate::workbook::{build_specs, check, ExcelMapper, Workbook}; +use crate::workbook::{build_specs, check, ExcelMapper, Sheet, Workbook}; use crate::{Error, XL_ERROR}; -/// Schema-driven parse of the current sheet into an Arrow [`RecordBatch`], using the same +/// Schema-driven parse of one sheet into an Arrow [`RecordBatch`], using the same /// `#[derive(ExcelMapper)]` mapping as [`crate::workbook::parse_sheet`]. /// -/// `header_row` has the same meaning as in `parse_sheet` (0 = no header). Takes `&mut Workbook` -/// because the parse consumes the workbook's shared row cursor. -pub fn parse_arrow( - workbook: &mut Workbook, - header_row: i32, -) -> Result { +/// `header_row` has the same meaning as in `parse_sheet` (0 = no header). +pub fn parse_arrow(sheet: Sheet<'_>, header_row: i32) -> Result { let arena = build_specs::(); let mut array = FFI_ArrowArray::empty(); @@ -32,10 +28,12 @@ pub fn parse_arrow( check(unsafe { crate::xl_parse_arrow( - workbook.handle(), + sheet.handle(), + sheet.index(), arena.specs.as_ptr(), arena.specs.len() as i32, header_row, + 1, &mut array as *mut FFI_ArrowArray as *mut c_void, &mut schema as *mut FFI_ArrowSchema as *mut c_void, ) @@ -58,7 +56,7 @@ pub fn parse_arrow( /// not fuse after an error, so break on the first `Err` rather than spinning on the latched one. pub struct ArrowChunks<'a> { inner: ArrowArrayStreamReader, - _workbook: PhantomData<&'a mut Workbook>, + _workbook: PhantomData<&'a Workbook>, } impl Iterator for ArrowChunks<'_> { @@ -76,12 +74,9 @@ impl RecordBatchReader for ArrowChunks<'_> { } /// `parse_arrow` delivered a batch at a time. `batch_size` is rows per batch, 0 unbounded, -/// negative an error. -/// -/// `&mut Workbook` because the stream borrows the workbook's single row cursor, and the ABI serves -/// one chunked read per workbook. +/// negative an error. Any number of streams may be open on one workbook. pub fn parse_arrow_stream( - workbook: &mut Workbook, + sheet: Sheet<'_>, header_row: i32, batch_size: i64, ) -> Result, Error> { @@ -91,7 +86,8 @@ pub fn parse_arrow_stream( check(unsafe { crate::xl_parse_arrow_stream( - workbook.handle(), + sheet.handle(), + sheet.index(), arena.specs.as_ptr(), arena.specs.len() as i32, header_row, diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index b4d7ef4a..2639471f 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -1,5 +1,5 @@ //! Raw FFI bindings to ExcelReader's C ABI (`excelreader.h`). The `extern "C"` block below is -//! declared directly in this crate root (not a submodule), so `crate::xl_open_file_ex` etc. resolve +//! declared directly in this crate root (not a submodule), so `crate::xl_open_file` etc. resolve //! from anywhere in the crate. See the `workbook` module for the safe wrapper built on top of this. pub mod aggregate; @@ -43,7 +43,7 @@ pub const XL_CELL_ERROR: i32 = 6; /// ABI revision this crate is compiled against. `Workbook::open` refuses to proceed when the loaded /// library's `xl_abi_version()` disagrees - see `workbook::check_abi_version`. -pub const XL_ABI_VERSION: i32 = 5; +pub const XL_ABI_VERSION: i32 = 6; pub const XL_T_STRING: i32 = 0; pub const XL_T_I64: i32 = 1; @@ -56,6 +56,10 @@ pub const XL_T_TIME: i32 = 5; /// Microseconds since 1970-01-01T00:00:00Z, stored as `i64`. pub const XL_T_TIMESTAMP: i32 = 6; +pub const XL_SHEET_VISIBLE: i32 = 0; +pub const XL_SHEET_HIDDEN: i32 = 1; +pub const XL_SHEET_VERY_HIDDEN: i32 = 2; + pub const XL_FORMAT_AUTO: i32 = 0; pub const XL_FORMAT_XLS: i32 = 1; pub const XL_FORMAT_XLSX: i32 = 2; @@ -75,6 +79,12 @@ pub struct XlWorkbook { _private: [u8; 0], } +/// Opaque row cursor (`xl_rows`) - never dereferenced by Rust, only passed back to `xl_rows_*`. +#[repr(C)] +pub struct XlRows { + _private: [u8; 0], +} + #[repr(C)] pub struct XlTypedReader { _private: [u8; 0], @@ -190,9 +200,9 @@ pub struct XlRow { pub cells: *mut XlRowCell, } -/// Mirrors `xl_rows`. +/// Mirrors `xl_rows_decoded`. #[repr(C)] -pub struct XlRows { +pub struct XlRowsDecoded { pub row_count: i32, pub rows: *mut XlRow, } @@ -228,7 +238,7 @@ pub struct XlCsvParallelOptions { extern "C" { pub fn xl_abi_version() -> c_int; - pub fn xl_open_file_ex( + pub fn xl_open_file( path: *const u8, path_len: i32, format: i32, @@ -236,7 +246,7 @@ extern "C" { out_handle: *mut *mut XlWorkbook, ) -> c_int; - pub fn xl_open_memory_ex( + pub fn xl_open_memory( data: *const u8, data_len: i32, format: i32, @@ -248,30 +258,37 @@ extern "C" { pub fn xl_sheet_count(handle: *mut XlWorkbook, out_count: *mut i32) -> c_int; - pub fn xl_sheet_name( + pub fn xl_sheet_name_at( handle: *mut XlWorkbook, + index: i32, buffer: *mut u8, capacity: i32, out_len: *mut i32, ) -> c_int; - pub fn xl_sheet_name_at( + pub fn xl_sheet_visibility_at( handle: *mut XlWorkbook, index: i32, - buffer: *mut u8, - capacity: i32, - out_len: *mut i32, + out_visibility: *mut i32, ) -> c_int; - pub fn xl_move_to_sheet(handle: *mut XlWorkbook, index: i32) -> c_int; + /// A name that matches no sheet is `XL_OK` with `-1`. + pub fn xl_sheet_index( + handle: *mut XlWorkbook, + name: *const u8, + name_len: i32, + out_index: *mut i32, + ) -> c_int; pub fn xl_is_date1904(handle: *mut XlWorkbook, out_flag: *mut i32) -> c_int; pub fn xl_parse_typed( handle: *mut XlWorkbook, + sheet: i32, specs: *const XlColumnSpec, spec_count: i32, header_row: i32, + degree_of_parallelism: i32, out_table: *mut XlTable, ) -> c_int; @@ -285,9 +302,11 @@ extern "C" { /// through `xl_free_table`. On any other status both outputs are left untouched. pub fn xl_parse_arrow( handle: *mut XlWorkbook, + sheet: i32, specs: *const XlColumnSpec, spec_count: i32, header_row: i32, + degree_of_parallelism: i32, out_array: *mut c_void, out_schema: *mut c_void, ) -> c_int; @@ -296,6 +315,7 @@ extern "C" { /// `xl_parse_arrow`'s outputs are. Its `release` is what closes the underlying read. pub fn xl_parse_arrow_stream( handle: *mut XlWorkbook, + sheet: i32, specs: *const XlColumnSpec, spec_count: i32, header_row: i32, @@ -305,10 +325,10 @@ extern "C" { pub fn xl_free_table(table: *mut XlTable); - /// `max_rows` is rows per batch, 0 meaning one unbounded batch. A workbook serves one chunked - /// read at a time; opening a second is `XL_ERROR`. + /// `max_rows` is rows per batch, 0 meaning one unbounded batch. pub fn xl_typed_reader_open( handle: *mut XlWorkbook, + sheet: i32, specs: *const XlColumnSpec, spec_count: i32, header_row: i32, @@ -324,8 +344,10 @@ extern "C" { pub fn xl_infer_schema( handle: *mut XlWorkbook, + sheet: i32, header_row: i32, sample_size: i32, + flags: i32, out_schema: *mut XlInferredSchema, ) -> c_int; @@ -356,7 +378,7 @@ extern "C" { /// Reads the plaintext XLSX/XLSB package at `package_path` and writes its agile-encrypted /// (ECMA-376 4.4) counterpart to `destination_path`, overwriting an existing file. The result - /// opens with the same password via `xl_open_file_ex`'s `xl_open_options::password`. + /// opens with the same password via `xl_open_file`'s `xl_open_options::password`. pub fn xl_encrypt_package( package_path: *const u8, package_path_len: i32, @@ -403,21 +425,22 @@ extern "C" { /// no-op. pub fn xl_write_handle_bytes(handle: *mut XlWriterHandle, out_buffer: *mut XlBuffer) -> c_int; - pub fn xl_next_row( - handle: *mut XlWorkbook, + pub fn xl_rows_open(handle: *mut XlWorkbook, sheet: i32, out_rows: *mut *mut XlRows) -> c_int; + pub fn xl_rows_close(rows: *mut XlRows) -> c_int; + pub fn xl_rows_next( + rows: *mut XlRows, buffer: *mut u8, capacity: i32, out_written: *mut i32, ) -> c_int; - pub fn xl_read_all_decoded(handle: *mut XlWorkbook, out_rows: *mut XlRows) -> c_int; - pub fn xl_free_rows(rows: *mut XlRows); - - pub fn xl_read_all_blob( - handle: *mut XlWorkbook, + pub fn xl_rows_read_all_blob( + rows: *mut XlRows, buffer: *mut u8, capacity: i32, out_written: *mut i32, ) -> c_int; + pub fn xl_rows_read_all_decoded(rows: *mut XlRows, out_rows: *mut XlRowsDecoded) -> c_int; + pub fn xl_free_rows(rows: *mut XlRowsDecoded); /// Folds a CSV file into one caller-owned accumulator across several threads. Blocks until the /// run finishes; the surviving state is written to `out_state` only on `XL_OK` and becomes the diff --git a/rust/excelreader/src/rows.rs b/rust/excelreader/src/rows.rs index 8b1cb561..68927826 100644 --- a/rust/excelreader/src/rows.rs +++ b/rust/excelreader/src/rows.rs @@ -59,8 +59,8 @@ impl<'a> CellRef<'a> { } } -/// Where a `RowRef`'s cells live. Blob rows come from `xl_next_row`, decoded rows from -/// `xl_read_all_decoded`. +/// Where a `RowRef`'s cells live. Blob rows come from `xl_rows_next`, decoded rows from +/// `xl_rows_read_all_decoded`. #[derive(Debug, Clone, Copy)] enum RowBacking<'a> { /// The bytes AFTER the leading `int32 cell_count`. @@ -76,7 +76,7 @@ pub struct RowRef<'a> { } impl<'a> RowRef<'a> { - /// Parses the leading cell count off a `xl_next_row` blob. `None` when the blob is too short to + /// Parses the leading cell count off a `xl_rows_next` blob. `None` when the blob is too short to /// hold even that count. pub(crate) fn from_blob(blob: &'a [u8]) -> Option> { let count = read_i32(blob, 0)?; @@ -93,7 +93,7 @@ impl<'a> RowRef<'a> { /// /// # Safety /// `cells` must point to `count` initialized `XlRowCell` values whose `value` pointers stay - /// valid for `'a` — that is, until `xl_free_rows` releases the enclosing `XlRows`. + /// valid for `'a` — that is, until `xl_free_rows` releases the enclosing `XlRowsDecoded`. pub(crate) unsafe fn from_decoded(cells: *const XlRowCell, count: i32) -> RowRef<'a> { let len = if count > 0 { count as usize } else { 0 }; let slice = if len == 0 || cells.is_null() { @@ -216,25 +216,36 @@ fn cell_from_decoded(raw: &XlRowCell) -> Option> { /// Rows are usually well under this; it only sets how often the first oversized row costs a retry. const INITIAL_ROW_BUFFER: usize = 64 * 1024; -/// A row-at-a-time reader over a workbook's current sheet, holding one reusable buffer. +/// A row-at-a-time reader over one sheet, holding one reusable buffer and one native cursor, +/// which it closes on drop. /// /// Not an `Iterator`: each row borrows the buffer that the next call overwrites, which /// `Iterator::next` cannot express. One row is alive at a time, enforced by the borrow checker. pub struct RowCursor<'w> { - handle: *mut crate::XlWorkbook, + rows: *mut crate::XlRows, buffer: Vec, written: usize, - workbook: std::marker::PhantomData<&'w mut crate::workbook::Workbook>, + workbook: std::marker::PhantomData<&'w crate::workbook::Workbook>, } +// SAFETY: the cursor owns its native enumerator and ABI v6 lets one thread at a time use it; +// every method that touches it takes `&mut self`. +unsafe impl Send for RowCursor<'_> {} + impl<'w> RowCursor<'w> { - pub(crate) fn new(handle: *mut crate::XlWorkbook) -> RowCursor<'w> { - RowCursor { - handle, + pub(crate) fn open(handle: *mut crate::XlWorkbook, sheet: i32) -> Result, Error> { + let mut rows: *mut crate::XlRows = std::ptr::null_mut(); + crate::workbook::check(unsafe { crate::xl_rows_open(handle, sheet, &mut rows) })?; + Ok(RowCursor { + rows, buffer: vec![0; INITIAL_ROW_BUFFER], written: 0, workbook: std::marker::PhantomData, - } + }) + } + + pub(crate) fn raw(&self) -> *mut crate::XlRows { + self.rows } /// Advances to the next row. `None` at end of sheet. @@ -246,7 +257,7 @@ impl<'w> RowCursor<'w> { let mut written: i32 = 0; let capacity = i32::try_from(self.buffer.len()).unwrap_or(i32::MAX); let status = unsafe { - crate::xl_next_row(self.handle, self.buffer.as_mut_ptr(), capacity, &mut written) + crate::xl_rows_next(self.rows, self.buffer.as_mut_ptr(), capacity, &mut written) }; match status { @@ -276,16 +287,22 @@ impl<'w> RowCursor<'w> { } } +impl Drop for RowCursor<'_> { + fn drop(&mut self) { + unsafe { crate::xl_rows_close(self.rows) }; + } +} + /// Every remaining row of a sheet, decoded natively in one call. /// /// Owns the native allocation and releases it on drop. Rows and cells borrow from it, so they /// cannot outlive it. pub struct DecodedRows { - raw: crate::XlRows, + raw: crate::XlRowsDecoded, } impl DecodedRows { - pub(crate) fn new(raw: crate::XlRows) -> DecodedRows { + pub(crate) fn new(raw: crate::XlRowsDecoded) -> DecodedRows { DecodedRows { raw } } @@ -328,13 +345,13 @@ impl std::fmt::Debug for DecodedRows { /// Rows are usually well under this; it only sets how often an oversized sheet costs a retry. const INITIAL_ALL_ROWS_BUFFER: usize = 1024 * 1024; -/// Every remaining row of a sheet, read into one flat buffer by a single `xl_read_all_blob` call. +/// Every remaining row of a sheet, read into one flat buffer by a single `xl_rows_read_all_blob` call. /// -/// Unlike [`DecodedRows`], the native side allocates nothing per row/cell here - `xl_read_all_blob` +/// Unlike [`DecodedRows`], the native side allocates nothing per row/cell here - `xl_rows_read_all_blob` /// writes the same wire format `RowCursor::next_row` decodes, one row after another with a length /// prefix, into a buffer this type owns as a plain `Vec`. That means no `xl_free_*` call on /// drop (there is nothing native to release) and one native allocation total instead of one per -/// row - prefer this over [`Workbook::read_all_decoded`](crate::workbook::Workbook::read_all_decoded) +/// row - prefer this over [`Sheet::read_all_decoded`](crate::workbook::Sheet::read_all_decoded) /// unless something specifically needs the decoded-array shape. pub struct AllRows { buffer: Vec, @@ -344,13 +361,13 @@ pub struct AllRows { } impl AllRows { - pub(crate) fn read(handle: *mut crate::XlWorkbook) -> Result { + pub(crate) fn read(rows: *mut crate::XlRows) -> Result { let mut buffer = vec![0u8; INITIAL_ALL_ROWS_BUFFER]; loop { let mut written: i32 = 0; let capacity = i32::try_from(buffer.len()).unwrap_or(i32::MAX); let status = unsafe { - crate::xl_read_all_blob(handle, buffer.as_mut_ptr(), capacity, &mut written) + crate::xl_rows_read_all_blob(rows, buffer.as_mut_ptr(), capacity, &mut written) }; match status { @@ -401,7 +418,7 @@ impl std::fmt::Debug for AllRows { } } -/// Parses the `xl_read_all_blob` wire format's row_count and per-row length prefixes into byte +/// Parses the `xl_rows_read_all_blob` wire format's row_count and per-row length prefixes into byte /// ranges. Bounds-checked throughout - a malformed buffer (which a correct native library never /// produces) is rejected rather than trusted into a panic or an out-of-bounds slice. fn parse_row_ranges(buffer: &[u8]) -> Result, Error> { @@ -440,7 +457,7 @@ fn malformed_all_rows_blob() -> Error { mod tests { use super::*; - /// Builds a row blob in the wire format documented on `xl_next_row`: + /// Builds a row blob in the wire format documented on `xl_rows_next`: /// `int32 cell_count`, then per cell `int32 column, int32 type, int32 value_len, bytes`. fn blob(cells: &[(i32, i32, &str)]) -> Vec { let mut out = Vec::new(); @@ -506,7 +523,7 @@ mod tests { assert_eq!(CellType::from_raw(XL_CELL_ERROR), Some(CellType::Error)); } - /// Builds an `xl_read_all_blob` buffer: `int32 row_count`, then each row as + /// Builds an `xl_rows_read_all_blob` buffer: `int32 row_count`, then each row as /// `int32 row_length` followed by that row's `blob()` bytes. fn all_rows_blob(rows: &[&[(i32, i32, &str)]]) -> Vec { let mut out = Vec::new(); diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs index f012f3e0..50cde0fb 100644 --- a/rust/excelreader/src/workbook.rs +++ b/rust/excelreader/src/workbook.rs @@ -97,7 +97,7 @@ fn warm_up() { let mut handle: *mut XlWorkbook = std::ptr::null_mut(); let status = unsafe { - crate::xl_open_memory_ex( + crate::xl_open_memory( CSV.as_ptr(), CSV.len() as i32, crate::XL_FORMAT_CSV, @@ -128,12 +128,17 @@ fn warm_up() { let _ = crate::aggregate::aggregate_csv_memory_unchecked(CSV, || CountRows(0), &options); } -/// An open workbook. Not thread-safe - use one per thread, same contract as the C ABI. (The raw -/// handle makes this type neither `Send` nor `Sync`, so the compiler enforces that for you.) +/// An open workbook. Safe to share between threads: every read goes through a [`Sheet`] and opens +/// its own cursor, so reads on one workbook never disturb one another. pub struct Workbook { handle: *mut XlWorkbook, } +// SAFETY: ABI v6 documents the workbook handle as usable from several threads at once. The only +// state behind it is immutable after open; each read owns its enumerator. +unsafe impl Send for Workbook {} +unsafe impl Sync for Workbook {} + impl Workbook { /// Opens `path`, sniffing the format and using every library default. /// @@ -156,7 +161,7 @@ impl Workbook { .map_or(std::ptr::null(), crate::options::OpenOptionsRaw::as_ptr); let mut handle: *mut XlWorkbook = std::ptr::null_mut(); let status = unsafe { - crate::xl_open_file_ex( + crate::xl_open_file( path.as_ptr(), path.len() as i32, format, @@ -182,7 +187,7 @@ impl Workbook { .map_or(std::ptr::null(), crate::options::OpenOptionsRaw::as_ptr); let mut handle: *mut XlWorkbook = std::ptr::null_mut(); let status = unsafe { - crate::xl_open_memory_ex( + crate::xl_open_memory( data.as_ptr(), data.len() as i32, format, @@ -201,15 +206,7 @@ impl Workbook { Ok(count) } - /// Name of the currently selected sheet. - pub fn sheet_name(&self) -> Result { - self.fill_string(|handle, buffer, capacity, out_len| unsafe { - crate::xl_sheet_name(handle, buffer, capacity, out_len) - }) - } - - /// Name of the sheet at `index`, without changing the current sheet or disturbing row - /// enumeration. + /// Name of the sheet at `index`. pub fn sheet_name_at(&self, index: i32) -> Result { self.fill_string(|handle, buffer, capacity, out_len| unsafe { crate::xl_sheet_name_at(handle, index, buffer, capacity, out_len) @@ -223,13 +220,6 @@ impl Workbook { .collect() } - /// Selects the sheet at `index`, resetting row enumeration to its first row. - /// - /// Takes `&mut self` because it moves the cursor every subsequent read shares. - pub fn move_to_sheet(&mut self, index: i32) -> Result<(), Error> { - check(unsafe { crate::xl_move_to_sheet(self.handle, index) }) - } - /// Whether the workbook uses the 1904 date system - needed to interpret raw Excel serial dates. pub fn is_date1904(&self) -> Result { let mut flag: i32 = 0; @@ -237,48 +227,156 @@ impl Workbook { Ok(flag != 0) } - /// A row-at-a-time reader over the current sheet. - /// - /// Takes `&mut self` because it advances the row cursor every read on this handle shares, the - /// same reason [`move_to_sheet`](Self::move_to_sheet) does. - pub fn rows(&mut self) -> crate::rows::RowCursor<'_> { - crate::rows::RowCursor::new(self.handle) + /// The sheet at `index`. An index outside the workbook is an error here, not at the first read. + pub fn sheet(&self, index: i32) -> Result, Error> { + let count = self.sheet_count()?; + if index < 0 || index >= count { + return Err(Error::from_status( + XL_ERROR, + format!("sheet index {index} is out of range; the workbook has {count} sheets"), + )); + } + Ok(Sheet { workbook: self, index }) } - /// Every remaining row of the current sheet in one native call, avoiding a round-trip per row. - /// - /// An empty remainder is an empty result, not an error. - pub fn read_all_decoded(&mut self) -> Result { - let mut raw = crate::XlRows { row_count: 0, rows: std::ptr::null_mut() }; - check(unsafe { crate::xl_read_all_decoded(self.handle, &mut raw) })?; + /// Every sheet, in workbook order. Opens nothing. + pub fn sheets(&self) -> Result>, Error> { + Ok((0..self.sheet_count()?) + .map(|index| Sheet { workbook: self, index }) + .collect()) + } + + /// The sheet called `name`, compared without regard to case. `Ok(None)` when no sheet matches. + pub fn sheet_by_name(&self, name: &str) -> Result>, Error> { + let mut index: i32 = -1; + check(unsafe { + crate::xl_sheet_index(self.handle, name.as_ptr(), name.len() as i32, &mut index) + })?; + Ok((index >= 0).then_some(Sheet { workbook: self, index })) + } + + /// Shared two-pass buffer dance for the `xl_*` functions that write a UTF-8 name into a caller + /// buffer and report the required capacity through `XL_BUFFER_TOO_SMALL`. + fn fill_string( + &self, + call: impl Fn(*mut XlWorkbook, *mut u8, i32, *mut i32) -> i32, + ) -> Result { + let mut buffer = [0u8; 128]; + let mut len: i32 = 0; + let mut status = call( + self.handle, + buffer.as_mut_ptr(), + buffer.len() as i32, + &mut len, + ); + if status != XL_BUFFER_TOO_SMALL { + check(status)?; + let buffer_slice = &buffer[..len.max(0) as usize]; + return str::from_utf8(buffer_slice) + .map(|s| s.to_string()) + .map_err(|e| { + Error::from_status( + XL_ERROR, + format!("native library returned a non-UTF-8 name: {e}"), + ) + }); + } + let mut vec_buffer = vec![0u8; len.max(0) as usize]; + status = call( + self.handle, + vec_buffer.as_mut_ptr(), + vec_buffer.len() as i32, + &mut len, + ); + check(status)?; + vec_buffer.truncate(len.max(0) as usize); + String::from_utf8(vec_buffer).map_err(|e| { + Error::from_status( + XL_ERROR, + format!("native library returned a non-UTF-8 name: {e}"), + ) + }) + } +} + +/// How a sheet is shown in the application's tab bar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SheetVisibility { + Visible, + Hidden, + VeryHidden, +} + +/// One sheet of a [`Workbook`]: the workbook and an index. Holds no native resource, so it is +/// `Copy` and free to pass to another thread; each read method opens what it needs. +#[derive(Clone, Copy)] +pub struct Sheet<'w> { + workbook: &'w Workbook, + index: i32, +} + +impl<'w> Sheet<'w> { + /// Zero-based position in the workbook. + #[must_use] + pub fn index(&self) -> i32 { + self.index + } + + pub fn name(&self) -> Result { + self.workbook.sheet_name_at(self.index) + } + + pub fn visibility(&self) -> Result { + let mut raw: i32 = 0; + check(unsafe { + crate::xl_sheet_visibility_at(self.workbook.handle, self.index, &mut raw) + })?; + match raw { + crate::XL_SHEET_VISIBLE => Ok(SheetVisibility::Visible), + crate::XL_SHEET_HIDDEN => Ok(SheetVisibility::Hidden), + crate::XL_SHEET_VERY_HIDDEN => Ok(SheetVisibility::VeryHidden), + other => Err(Error::from_status( + XL_ERROR, + format!("native library returned an unknown sheet visibility {other}"), + )), + } + } + + /// A row-at-a-time reader over this sheet, from its first row. Each call opens an independent + /// cursor, so several may be alive at once, on this thread or on others. + pub fn rows(&self) -> Result, Error> { + crate::rows::RowCursor::open(self.workbook.handle, self.index) + } + + /// Every row of this sheet in one native call, avoiding a round-trip per row. + pub fn read_all_decoded(&self) -> Result { + let cursor = self.rows()?; + let mut raw = crate::XlRowsDecoded { row_count: 0, rows: std::ptr::null_mut() }; + check(unsafe { crate::xl_rows_read_all_decoded(cursor.raw(), &mut raw) })?; Ok(crate::rows::DecodedRows::new(raw)) } - /// Every remaining row of the current sheet, read in one native call into a buffer this type - /// owns outright - no native allocation per row/cell, and no `xl_free_*` call needed on drop. + /// Every row of this sheet, read in one native call into a buffer this type owns outright. /// Prefer this over [`read_all_decoded`](Self::read_all_decoded) unless something specifically /// needs the decoded-array shape. - /// - /// An empty remainder is an empty result, not an error. - pub fn read_all_blob(&mut self) -> Result { - crate::rows::AllRows::read(self.handle) + pub fn read_all_blob(&self) -> Result { + let cursor = self.rows()?; + crate::rows::AllRows::read(cursor.raw()) } /// [`parse_sheet`] delivered a batch at a time. `batch_size` is rows per batch: 0 unbounded, /// negative an error. The header row is consumed once, here, not re-read per batch. - /// - /// `&mut self` is what makes a second reader - or a `parse_sheet`/[`rows`](Self::rows) call - /// alongside a live one - a compile error rather than a runtime fault. pub fn typed_chunks( - &mut self, + &self, header_row: i32, batch_size: i64, - ) -> Result, Error> { + ) -> Result, Error> { let arena = build_specs::(); let mut reader: *mut crate::XlTypedReader = std::ptr::null_mut(); check(unsafe { crate::xl_typed_reader_open( - self.handle, + self.workbook.handle, + self.index, arena.specs.as_ptr(), arena.specs.len() as i32, header_row, @@ -294,12 +392,11 @@ impl Workbook { }) } - /// Guesses a [`parse_sheet`] schema by sampling the current sheet. + /// Guesses a [`parse_sheet`] schema by sampling this sheet. /// /// `header_row` has the same meaning as in [`parse_sheet`] (0 = no header); `sample_size` /// bounds how many rows after the header are inspected. This is a guess over a sample, not a - /// guarantee - always check it fits before trusting it against the full sheet. Takes `&self`: - /// the native call samples independently of the shared row cursor and never disturbs it. + /// guarantee - always check it fits before trusting it against the full sheet. pub fn infer_schema( &self, header_row: i32, @@ -310,7 +407,14 @@ impl Workbook { column_count: 0, }; check(unsafe { - crate::xl_infer_schema(self.handle, header_row, sample_size, &mut schema) + crate::xl_infer_schema( + self.workbook.handle, + self.index, + header_row, + sample_size, + 0, + &mut schema, + ) })?; let columns = unsafe { copy_inferred(&schema) }; @@ -318,54 +422,15 @@ impl Workbook { Ok(columns) } - /// The raw handle, for sibling modules (e.g. `arrow::parse_arrow`) that need to call an - /// `xl_*` function this struct has no wrapper for yet. Not part of the crate's public surface - - /// `pub(crate)`, not `pub`. pub(crate) fn handle(&self) -> *mut XlWorkbook { - self.handle + self.workbook.handle } +} - /// Shared two-pass buffer dance for the `xl_*` functions that write a UTF-8 name into a caller - /// buffer and report the required capacity through `XL_BUFFER_TOO_SMALL`. - fn fill_string( - &self, - call: impl Fn(*mut XlWorkbook, *mut u8, i32, *mut i32) -> i32, - ) -> Result { - let mut buffer = [0u8; 128]; - let mut len: i32 = 0; - let mut status = call( - self.handle, - buffer.as_mut_ptr(), - buffer.len() as i32, - &mut len, - ); - if status != XL_BUFFER_TOO_SMALL { - check(status)?; - let buffer_slice = &buffer[..len.max(0) as usize]; - return str::from_utf8(buffer_slice) - .map(|s| s.to_string()) - .map_err(|e| { - Error::from_status( - XL_ERROR, - format!("native library returned a non-UTF-8 name: {e}"), - ) - }); - } - let mut vec_buffer = vec![0u8; len.max(0) as usize]; - status = call( - self.handle, - vec_buffer.as_mut_ptr(), - vec_buffer.len() as i32, - &mut len, - ); - check(status)?; - vec_buffer.truncate(len.max(0) as usize); - String::from_utf8(vec_buffer).map_err(|e| { - Error::from_status( - XL_ERROR, - format!("native library returned a non-UTF-8 name: {e}"), - ) - }) +/// The sheet's position only - reading its name would be a native call inside `Debug`. +impl std::fmt::Debug for Sheet<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sheet").field("index", &self.index).finish() } } @@ -401,7 +466,7 @@ unsafe fn copy_inferred(schema: &XlInferredSchema) -> Vec { .collect() } -/// One column guessed by [`Workbook::infer_schema`]. +/// One column guessed by [`Sheet::infer_schema`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InferredColumn { /// Header text, or `None` when the column must be resolved by [`index`](Self::index) instead. @@ -627,9 +692,13 @@ pub struct TypedChunks<'a, T: ExcelMapper> { reader: *mut crate::XlTypedReader, bindings: Vec>, done: bool, - _workbook: PhantomData<&'a mut Workbook>, + _workbook: PhantomData<&'a Workbook>, } +// SAFETY: a typed reader owns its native enumerator; ABI v6 lets it be used from any one thread +// at a time, which `&mut self` on `next` already guarantees. +unsafe impl Send for TypedChunks<'_, T> {} + impl Iterator for TypedChunks<'_, T> { type Item = Result, Error>; @@ -759,11 +828,9 @@ pub(crate) fn build_specs() -> SpecArena { } } -/// Schema-driven columnar parse of the current sheet, matching C++'s `xl::parse_sheet`. -/// -/// Takes `&mut Workbook` because the parse consumes the workbook's shared row cursor. +/// Schema-driven columnar parse of one sheet, matching C++'s `xl::parse_sheet`. pub fn parse_sheet( - workbook: &mut Workbook, + sheet: Sheet<'_>, header_row: i32, ) -> Result, Error> { let arena = build_specs::(); @@ -775,10 +842,12 @@ pub fn parse_sheet( }; unsafe { let status = crate::xl_parse_typed( - workbook.handle, + sheet.handle(), + sheet.index(), arena.specs.as_ptr(), arena.specs.len() as i32, header_row, + 1, &mut table, ); if status != XL_OK { diff --git a/rust/excelreader/tests/parse_arrow.rs b/rust/excelreader/tests/parse_arrow.rs index 2c77c40a..50f69113 100644 --- a/rust/excelreader/tests/parse_arrow.rs +++ b/rust/excelreader/tests/parse_arrow.rs @@ -19,8 +19,8 @@ fn fixture_path() -> String { #[test] fn parse_arrow_returns_a_record_batch_with_one_column_per_field() { - let mut workbook = Workbook::open(&fixture_path()).expect("open must succeed"); - let batch = parse_arrow::(&mut workbook, 1).expect("parse_arrow must succeed"); + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + let batch = parse_arrow::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_arrow must succeed"); assert_eq!(batch.num_columns(), 2); assert_eq!(batch.num_rows(), 100); @@ -34,18 +34,18 @@ fn parse_arrow_returns_a_record_batch_with_one_column_per_field() { #[test] fn parse_arrow_reports_an_error_without_leaving_a_half_built_batch() { - let mut workbook = Workbook::open(&fixture_path()).expect("open must succeed"); - let result = parse_arrow::(&mut workbook, 1_000_000); + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + let result = parse_arrow::(workbook.sheet(0).expect("sheet 0"), 1_000_000); assert!(result.is_err()); - let batch = parse_arrow::(&mut workbook, 1).expect("parse_arrow must succeed"); + let batch = parse_arrow::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_arrow must succeed"); assert_eq!(batch.num_rows(), 100); } #[test] fn arrow_stream_batches_match_the_whole_sheet() { - let mut workbook = Workbook::open(&fixture_path()).expect("open must succeed"); - let stream = parse_arrow_stream::(&mut workbook, 1, 8).expect("the stream must open"); + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + let stream = parse_arrow_stream::(workbook.sheet(0).expect("sheet 0"), 1, 8).expect("the stream must open"); let schema = stream.schema(); let mut rows = 0; @@ -64,15 +64,29 @@ fn arrow_stream_batches_match_the_whole_sheet() { #[test] fn arrow_stream_rejects_a_negative_batch_size() { - let mut workbook = Workbook::open(&fixture_path()).expect("open must succeed"); - assert!(parse_arrow_stream::(&mut workbook, 1, -1).is_err()); + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + assert!(parse_arrow_stream::(workbook.sheet(0).expect("sheet 0"), 1, -1).is_err()); } #[test] fn arrow_stream_with_batch_size_zero_yields_one_batch() { - let mut workbook = Workbook::open(&fixture_path()).expect("open must succeed"); - let stream = parse_arrow_stream::(&mut workbook, 1, 0).expect("the stream must open"); + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + let stream = parse_arrow_stream::(workbook.sheet(0).expect("sheet 0"), 1, 0).expect("the stream must open"); let batches: Vec<_> = stream.map(|b| b.expect("a batch must read")).collect(); assert_eq!(batches.len(), 1, "batch_size 0 is one whole-sheet batch"); assert_eq!(batches[0].num_rows(), 100); } + +#[test] +fn two_arrow_streams_on_one_workbook_each_yield_every_row() { + let workbook = Workbook::open(&fixture_path()).expect("open must succeed"); + let sheet = workbook.sheet(0).expect("sheet 0"); + let one = parse_arrow_stream::(sheet, 1, 8).expect("the first stream must open"); + let two = parse_arrow_stream::(sheet, 1, 8).expect("a second stream must open alongside it"); + + let total = |stream: excelreader::arrow::ArrowChunks<'_>| -> usize { + stream.map(|batch| batch.expect("a batch must read").num_rows()).sum() + }; + assert_eq!(total(one), 100); + assert_eq!(total(two), 100); +} diff --git a/rust/excelreader/tests/parse_typed.rs b/rust/excelreader/tests/parse_typed.rs index c21d08d7..d83b15e4 100644 --- a/rust/excelreader/tests/parse_typed.rs +++ b/rust/excelreader/tests/parse_typed.rs @@ -39,8 +39,8 @@ fn open_fixture() -> Workbook { #[test] fn parses_real_excel_fixture() { - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); assert_eq!(table.len(), 100); let first = table.get(0).expect("row 0 is in bounds"); @@ -53,8 +53,8 @@ fn parses_real_excel_fixture() { #[test] fn get_returns_none_outside_the_row_range() { - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); assert!( table.get(table.len() - 1).is_some(), @@ -67,8 +67,8 @@ fn get_returns_none_outside_the_row_range() { #[test] fn iter_yields_exactly_len_rows_and_reports_it_up_front() { - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); let iter = table.iter(); assert_eq!(iter.len(), 100, "ExactSizeIterator must agree with len()"); @@ -77,8 +77,8 @@ fn iter_yields_exactly_len_rows_and_reports_it_up_front() { #[test] fn parses_integer_widths_floats_and_dates() { - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); let first = table.get(0).expect("row 0 is in bounds"); assert_eq!(first.coluna1, "Valor1"); @@ -89,8 +89,8 @@ fn parses_integer_widths_floats_and_dates() { #[test] fn resolves_the_first_alias_present_in_the_header_row() { - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed via alias"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed via alias"); assert_eq!(table.len(), 100); let first = table.get(0).expect("row 0 is in bounds"); assert_eq!(first.coluna1, "Valor1"); @@ -107,53 +107,29 @@ fn parses_dates_straight_into_chrono() { coluna2: NaiveDate, } - let mut workbook = open_fixture(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open_fixture(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); let first = table.get(0).expect("row 0 is in bounds"); assert_eq!(first.coluna2, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); } #[test] fn exposes_sheet_navigation() { - let mut workbook = open_fixture(); + let workbook = open_fixture(); let count = workbook.sheet_count().expect("sheet_count must succeed"); assert!(count >= 1, "the fixture has at least one sheet"); let names = workbook.sheet_names().expect("sheet_names must succeed"); assert_eq!(names.len(), count as usize); - assert_eq!( - workbook.sheet_name().expect("sheet_name must succeed"), - names[0], - "the first sheet is selected before any move_to_sheet" - ); - - workbook - .move_to_sheet(0) - .expect("move_to_sheet must succeed"); - assert_eq!(workbook.sheet_name().unwrap(), names[0]); workbook.is_date1904().expect("is_date1904 must succeed"); } -#[test] -fn move_to_sheet_rejects_an_index_past_the_end() { - let mut workbook = open_fixture(); - let count = workbook.sheet_count().unwrap(); - let error = workbook - .move_to_sheet(count) - .expect_err("an index past the last sheet must fail"); - assert!( - !error.message().is_empty(), - "the failure must carry the native detail, got: {error}" - ); -} - #[test] fn infers_a_schema_from_the_header_row() { let workbook = open_fixture(); - let schema = workbook - .infer_schema(1, 100) + let schema = workbook.sheet(0).expect("sheet 0").infer_schema(1, 100) .expect("infer_schema must succeed"); assert!(!schema.is_empty(), "the fixture has columns to infer"); @@ -169,33 +145,32 @@ fn infers_a_schema_from_the_header_row() { } #[test] -fn infer_schema_leaves_the_row_cursor_alone() { - let mut workbook = open_fixture(); - workbook - .infer_schema(1, 100) +fn infer_schema_leaves_the_sheet_readable() { + let workbook = open_fixture(); + workbook.sheet(0).expect("sheet 0").infer_schema(1, 100) .expect("infer_schema must succeed"); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); assert_eq!(table.len(), 100); } #[test] fn open_with_accepts_an_explicit_format_and_options() { let options = OpenOptions::new().prefetch_decompression(true); - let mut workbook = Workbook::open_with(&fixture_path(), XL_FORMAT_XLSB, Some(&options)) + let workbook = Workbook::open_with(&fixture_path(), XL_FORMAT_XLSB, Some(&options)) .expect("open_with must succeed"); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); assert_eq!(table.len(), 100); } #[test] fn open_memory_reads_the_same_bytes() { let bytes = std::fs::read(fixture_path()).expect("fixture must be readable"); - let mut workbook = + let workbook = Workbook::open_memory(&bytes, XL_FORMAT_XLSB, None).expect("open_memory must succeed"); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); assert_eq!(table.len(), 100); assert_eq!(table.get(0).unwrap().coluna1, "Valor1"); } @@ -217,8 +192,8 @@ fn parse_sheet_reports_the_native_error_for_an_unknown_column() { missing: String, } - let mut workbook = open_fixture(); - let error = parse_sheet::(&mut workbook, 1).expect_err("an unknown column must fail"); + let workbook = open_fixture(); + let error = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect_err("an unknown column must fail"); assert!( !error.message().is_empty(), "the failure must carry the native detail, got: {error}" diff --git a/rust/excelreader/tests/rows.rs b/rust/excelreader/tests/rows.rs index 6d5c4b5b..dcdfa875 100644 --- a/rust/excelreader/tests/rows.rs +++ b/rust/excelreader/tests/rows.rs @@ -1,5 +1,6 @@ use excelreader::workbook::Workbook; -use excelreader::CellType; +use excelreader::writer_handle::WriterHandle; +use excelreader::{CellType, XL_FORMAT_AUTO, XL_FORMAT_XLSX}; fn fixture(name: &str) -> String { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -9,10 +10,18 @@ fn fixture(name: &str) -> String { root.join(name).to_string_lossy().into_owned() } +fn empty_sheet_workbook() -> Workbook { + let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); + writer.start_sheet("Empty").expect("start sheet"); + writer.end_sheet().expect("end sheet"); + let bytes = writer.bytes().expect("bytes"); + Workbook::open_memory(&bytes, XL_FORMAT_AUTO, None).expect("open") +} + #[test] fn cursor_reads_the_header_row() { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let mut cursor = workbook.rows(); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let mut cursor = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); let row = cursor.next_row().expect("a first row").expect("no error"); let names: Vec = row.iter().map(|cell| cell.as_str().unwrap().to_string()).collect(); @@ -23,38 +32,25 @@ fn cursor_reads_the_header_row() { #[test] fn cursor_terminates_cleanly() { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); let mut counted = 0usize; { - let mut cursor = workbook.rows(); + let mut cursor = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); while let Some(row) = cursor.next_row() { row.expect("no error mid-sheet"); counted += 1; } + assert!(cursor.next_row().is_none()); } assert!(counted > 1, "fixture should have a header plus data rows"); - let mut cursor = workbook.rows(); - assert!(cursor.next_row().is_none()); -} - -#[test] -fn move_to_sheet_resets_the_cursor() { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let first: Vec = { - let mut cursor = workbook.rows(); - let row = cursor.next_row().expect("row").expect("ok"); - row.iter().map(|c| c.as_str().unwrap().to_string()).collect() - }; - - workbook.move_to_sheet(0).expect("re-select sheet 0"); - - let again: Vec = { - let mut cursor = workbook.rows(); - let row = cursor.next_row().expect("row").expect("ok"); - row.iter().map(|c| c.as_str().unwrap().to_string()).collect() - }; - assert_eq!(first, again); + let mut again = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); + let mut second = 0usize; + while let Some(row) = again.next_row() { + row.expect("no error mid-sheet"); + second += 1; + } + assert_eq!(second, counted, "a new cursor starts from the top"); } #[test] @@ -65,9 +61,9 @@ fn grows_its_buffer_for_an_oversized_row() { let path = dir.join("wide.csv"); std::fs::write(&path, format!("a\n{big}\n")).expect("write fixture"); - let mut workbook = + let workbook = Workbook::open_with(&path.to_string_lossy(), excelreader::XL_FORMAT_CSV, None).expect("open csv"); - let mut cursor = workbook.rows(); + let mut cursor = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); let header = cursor.next_row().expect("header").expect("ok"); assert_eq!(header.get(0).unwrap().as_str().unwrap(), "a"); @@ -85,8 +81,8 @@ fn grows_its_buffer_for_an_oversized_row() { #[test] fn decoded_rows_agree_with_the_cursor() { let from_cursor: Vec> = { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let mut cursor = workbook.rows(); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let mut cursor = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); let mut all = Vec::new(); while let Some(row) = cursor.next_row() { let row = row.expect("ok"); @@ -95,8 +91,8 @@ fn decoded_rows_agree_with_the_cursor() { all }; - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let decoded = workbook.read_all_decoded().expect("decode"); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let decoded = workbook.sheet(0).expect("sheet 0").read_all_decoded().expect("decode"); assert_eq!(decoded.len(), from_cursor.len()); for (index, expected) in from_cursor.iter().enumerate() { @@ -107,15 +103,9 @@ fn decoded_rows_agree_with_the_cursor() { } #[test] -fn decoded_rows_on_an_exhausted_sheet_is_empty_not_an_error() { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - { - let mut cursor = workbook.rows(); - while let Some(row) = cursor.next_row() { - row.expect("ok"); - } - } - let decoded = workbook.read_all_decoded().expect("empty remainder is not an error"); +fn decoded_rows_on_an_empty_sheet_is_empty_not_an_error() { + let workbook = empty_sheet_workbook(); + let decoded = workbook.sheet(0).expect("sheet 0").read_all_decoded().expect("an empty sheet is not an error"); assert_eq!(decoded.len(), 0); assert!(decoded.is_empty()); assert_eq!(decoded.iter().count(), 0); @@ -124,8 +114,8 @@ fn decoded_rows_on_an_exhausted_sheet_is_empty_not_an_error() { #[test] fn all_rows_blob_agrees_with_the_cursor() { let from_cursor: Vec> = { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let mut cursor = workbook.rows(); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let mut cursor = workbook.sheet(0).expect("sheet 0").rows().expect("cursor"); let mut all = Vec::new(); while let Some(row) = cursor.next_row() { let row = row.expect("ok"); @@ -134,8 +124,8 @@ fn all_rows_blob_agrees_with_the_cursor() { all }; - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - let all_rows = workbook.read_all_blob().expect("read_all_blob"); + let workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); + let all_rows = workbook.sheet(0).expect("sheet 0").read_all_blob().expect("read_all_blob"); assert_eq!(all_rows.len(), from_cursor.len()); for (index, expected) in from_cursor.iter().enumerate() { @@ -146,15 +136,9 @@ fn all_rows_blob_agrees_with_the_cursor() { } #[test] -fn all_rows_blob_on_an_exhausted_sheet_is_empty_not_an_error() { - let mut workbook = Workbook::open(&fixture("RealExcel.xlsx")).expect("open"); - { - let mut cursor = workbook.rows(); - while let Some(row) = cursor.next_row() { - row.expect("ok"); - } - } - let all_rows = workbook.read_all_blob().expect("empty remainder is not an error"); +fn all_rows_blob_on_an_empty_sheet_is_empty_not_an_error() { + let workbook = empty_sheet_workbook(); + let all_rows = workbook.sheet(0).expect("sheet 0").read_all_blob().expect("an empty sheet is not an error"); assert_eq!(all_rows.len(), 0); assert!(all_rows.is_empty()); assert_eq!(all_rows.iter().count(), 0); @@ -168,9 +152,9 @@ fn all_rows_blob_grows_its_buffer_past_the_initial_size() { let path = dir.join("wide.csv"); std::fs::write(&path, format!("a\n{big}\n")).expect("write fixture"); - let mut workbook = + let workbook = Workbook::open_with(&path.to_string_lossy(), excelreader::XL_FORMAT_CSV, None).expect("open csv"); - let all_rows = workbook.read_all_blob().expect("read_all_blob"); + let all_rows = workbook.sheet(0).expect("sheet 0").read_all_blob().expect("read_all_blob"); assert_eq!(all_rows.len(), 2); assert_eq!(all_rows.get(0).unwrap().get(0).unwrap().as_str().unwrap(), "a"); diff --git a/rust/excelreader/tests/sheets.rs b/rust/excelreader/tests/sheets.rs new file mode 100644 index 00000000..08570246 --- /dev/null +++ b/rust/excelreader/tests/sheets.rs @@ -0,0 +1,110 @@ +use excelreader::workbook::{SheetVisibility, Workbook}; +use excelreader::writer_handle::WriterHandle; +use excelreader::{XL_FORMAT_AUTO, XL_FORMAT_XLSX}; + +fn two_sheets() -> Workbook { + let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); + for (name, rows) in [("First", vec!["a1", "a2"]), ("Second", vec!["b1", "b2", "b3"])] { + writer.start_sheet(name).expect("start sheet"); + for value in rows { + writer.start_row().expect("start row"); + writer.write_str(Some(value)).expect("write"); + writer.end_row().expect("end row"); + } + writer.end_sheet().expect("end sheet"); + } + let bytes = writer.bytes().expect("bytes"); + Workbook::open_memory(&bytes, XL_FORMAT_AUTO, None).expect("open") +} + +fn first_column(cursor: &mut excelreader::RowCursor<'_>) -> Vec { + let mut values = Vec::new(); + while let Some(row) = cursor.next_row() { + let row = row.expect("no error mid-sheet"); + values.push(row.get(0).expect("a cell").as_str().expect("utf-8").to_string()); + } + values +} + +#[test] +fn a_sheet_reads_its_own_rows() { + let workbook = two_sheets(); + let second = workbook.sheet(1).expect("sheet 1"); + let first = workbook.sheet(0).expect("sheet 0"); + + assert_eq!(first_column(&mut second.rows().expect("cursor")), ["b1", "b2", "b3"]); + assert_eq!(first_column(&mut first.rows().expect("cursor")), ["a1", "a2"]); +} + +#[test] +fn two_cursors_on_one_sheet_each_return_every_row() { + let workbook = two_sheets(); + let sheet = workbook.sheet(1).expect("sheet 1"); + let mut one = sheet.rows().expect("cursor one"); + let mut two = sheet.rows().expect("cursor two"); + + assert!(one.next_row().expect("a row").is_ok()); + assert!(two.next_row().expect("a row").is_ok()); + + assert_eq!(first_column(&mut one), ["b2", "b3"]); + assert_eq!(first_column(&mut two), ["b2", "b3"]); +} + +#[test] +fn a_cursor_dropped_midway_leaves_the_workbook_usable() { + let workbook = two_sheets(); + let sheet = workbook.sheet(1).expect("sheet 1"); + for _ in 0..1000 { + let mut cursor = sheet.rows().expect("cursor"); + assert!(cursor.next_row().expect("a row").is_ok()); + } + + assert_eq!(first_column(&mut sheet.rows().expect("cursor")), ["b1", "b2", "b3"]); +} + +#[test] +fn sheet_rejects_an_index_out_of_range_at_the_call() { + let workbook = two_sheets(); + + assert!(workbook.sheet(2).is_err()); + assert!(workbook.sheet(-1).is_err()); +} + +#[test] +fn sheets_lists_every_sheet_with_its_name_index_and_visibility() { + let workbook = two_sheets(); + let sheets = workbook.sheets().expect("sheets"); + + assert_eq!(sheets.len(), 2); + assert_eq!(sheets[1].index(), 1); + assert_eq!(sheets[1].name().expect("name"), "Second"); + assert_eq!(sheets[0].visibility().expect("visibility"), SheetVisibility::Visible); +} + +#[test] +fn sheet_by_name_ignores_case_and_reports_a_miss_as_none() { + let workbook = two_sheets(); + + assert_eq!(workbook.sheet_by_name("Second").expect("ok").expect("found").index(), 1); + assert_eq!(workbook.sheet_by_name("sEcOnD").expect("ok").expect("found").index(), 1); + assert!(workbook.sheet_by_name("Missing").expect("ok").is_none()); +} + +#[test] +fn read_all_reads_the_named_sheet() { + let workbook = two_sheets(); + let sheet = workbook.sheet(1).expect("sheet 1"); + + assert_eq!(sheet.read_all_decoded().expect("decoded").len(), 3); + assert_eq!(sheet.read_all_blob().expect("blob").len(), 3); + assert_eq!(workbook.sheet(0).expect("sheet 0").read_all_blob().expect("blob").len(), 2); +} + +#[test] +fn infer_schema_samples_the_named_sheet() { + let workbook = two_sheets(); + + let columns = workbook.sheet(1).expect("sheet 1").infer_schema(0, 10).expect("schema"); + + assert_eq!(columns.len(), 1); +} diff --git a/rust/excelreader/tests/typed_reader.rs b/rust/excelreader/tests/typed_reader.rs index f115a3b0..71048808 100644 --- a/rust/excelreader/tests/typed_reader.rs +++ b/rust/excelreader/tests/typed_reader.rs @@ -18,8 +18,8 @@ fn open() -> Workbook { } fn whole_sheet() -> Vec<(String, i64)> { - let mut workbook = open(); - let table = parse_sheet::(&mut workbook, 1).expect("parse_sheet must succeed"); + let workbook = open(); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("parse_sheet must succeed"); (0..table.len()) .map(|i| { let row = table.get(i).expect("row must be in range"); @@ -36,9 +36,8 @@ fn batches_equal_the_whole_sheet() { assert_eq!(expected.len(), 100, "RealExcel.xlsb has 100 data rows"); for batch_size in [1_i64, 7, 8, 9, 1000, 0, 110] { - let mut workbook = open(); - let chunks = workbook - .typed_chunks::(1, batch_size) + let workbook = open(); + let chunks = workbook.sheet(0).expect("sheet 0").typed_chunks::(1, batch_size) .expect("typed_chunks must open"); let mut actual = Vec::new(); @@ -65,9 +64,8 @@ fn batches_equal_the_whole_sheet() { #[test] fn a_negative_batch_size_is_rejected() { - let mut workbook = open(); - let error = workbook - .typed_chunks::(1, -1) + let workbook = open(); + let error = workbook.sheet(0).expect("sheet 0").typed_chunks::(1, -1) .expect_err("a negative batch size must be rejected"); assert_eq!(error.code(), XL_INVALID_ARGUMENT); } @@ -76,27 +74,25 @@ fn a_negative_batch_size_is_rejected() { /// reopen, repeat the last batch, or spin on a latched error. #[test] fn the_iterator_stays_exhausted_after_eof() { - let mut workbook = open(); - let mut chunks = workbook.typed_chunks::(1, 8).expect("typed_chunks must open"); + let workbook = open(); + let mut chunks = workbook.sheet(0).expect("sheet 0").typed_chunks::(1, 8).expect("typed_chunks must open"); while chunks.next().is_some() {} assert!(chunks.next().is_none()); assert!(chunks.next().is_none()); } -/// The workbook is usable again once the reader is dropped. A second reader while the first is -/// alive is not tested here because it does not compile: `typed_chunks` takes `&mut self`, which is -/// exactly the guarantee this binding buys over C++ and Python. +/// Readers are independent: any number may be open on one workbook, and dropping one leaves the +/// others and the workbook usable. #[test] -fn the_workbook_is_reusable_after_the_reader_is_dropped() { - let mut workbook = open(); - { - let chunks = workbook.typed_chunks::(1, 8).expect("first reader must open"); - drop(chunks); - } - let rows: i64 = workbook - .typed_chunks::(1, 8) - .expect("a second reader must open once the first is dropped") - .map(|batch| batch.expect("a batch must read").len()) - .sum(); - assert_eq!(rows, 100); +fn two_readers_on_one_workbook_each_yield_every_row() { + let workbook = open(); + let sheet = workbook.sheet(0).expect("sheet 0"); + let one = sheet.typed_chunks::(1, 8).expect("first reader must open"); + let two = sheet.typed_chunks::(1, 8).expect("a second reader must open alongside it"); + + let count = |chunks: excelreader::workbook::TypedChunks<'_, Record>| -> i64 { + chunks.map(|batch| batch.expect("a batch must read").len()).sum() + }; + assert_eq!(count(one), 100); + assert_eq!(count(two), 100); } diff --git a/rust/excelreader/tests/write_typed.rs b/rust/excelreader/tests/write_typed.rs index c81051cd..bea0bec3 100644 --- a/rust/excelreader/tests/write_typed.rs +++ b/rust/excelreader/tests/write_typed.rs @@ -97,9 +97,9 @@ fn write_columns_round_trips_every_column_type() { excelreader::writer::write_columns(target, XL_FORMAT_XLSX, &columns, None) .expect("write_columns must succeed"); - let mut workbook = Workbook::open(target).expect("the written file must open"); + let workbook = Workbook::open(target).expect("the written file must open"); let table = - parse_sheet::(&mut workbook, 1).expect("the written file must parse back"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the written file must parse back"); assert_eq!(table.len(), 2); let first = table.get(0).expect("row 0 must exist"); @@ -145,7 +145,7 @@ fn write_columns_to_memory_round_trips_every_column_type() { .expect("write_columns_to_memory must succeed"); assert!(!bytes.is_empty()); - let mut workbook = + let workbook = Workbook::open_memory(&bytes, XL_FORMAT_XLSX, None).expect("the returned bytes must open"); #[derive(Default, Debug, ExcelMapper)] struct TwoColumnRow { @@ -154,7 +154,7 @@ fn write_columns_to_memory_round_trips_every_column_type() { #[excel(name = "inteiro")] inteiro: i64, } - let table = parse_sheet::(&mut workbook, 1) + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1) .expect("the returned bytes must parse back"); assert_eq!(table.len(), 2); assert_eq!(table.get(0).expect("row 0").texto, "uma"); @@ -182,9 +182,9 @@ fn write_columns_writes_nulls_from_the_validity_bitmap() { excelreader::writer::write_columns(target, XL_FORMAT_XLSX, &columns, None) .expect("write_columns must succeed"); - let mut workbook = Workbook::open(target).expect("the written file must open"); + let workbook = Workbook::open(target).expect("the written file must open"); let table = - parse_sheet::(&mut workbook, 1).expect("the written file must parse back"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the written file must parse back"); assert_eq!(table.get(0).unwrap().quantidade, Some(10)); assert_eq!(table.get(1).unwrap().quantidade, None); assert_eq!(table.get(2).unwrap().quantidade, Some(30)); @@ -269,7 +269,7 @@ fn write_columns_honors_the_sheet_name_and_csv_format() { .expect("write must succeed"); let workbook = Workbook::open(target).expect("the written file must open"); - assert_eq!(workbook.sheet_name().expect("name must read back"), "Dados"); + assert_eq!(workbook.sheet_name_at(0).expect("name must read back"), "Dados"); drop(workbook); std::fs::remove_file(&path).ok(); @@ -365,9 +365,9 @@ fn write_sheet_round_trips_a_hand_written_excel_writer() { excelreader::writer::write_sheet(target, XL_FORMAT_XLSX, &rows, None) .expect("write_sheet must succeed"); - let mut workbook = Workbook::open(target).expect("the written file must open"); + let workbook = Workbook::open(target).expect("the written file must open"); let table = - parse_sheet::(&mut workbook, 1).expect("the written file must parse back"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the written file must parse back"); assert_eq!(table.len(), 2); let first = table.get(0).expect("row 0 must exist"); @@ -433,9 +433,9 @@ fn the_derive_round_trips_every_supported_field_type() { excelreader::writer::write_sheet(target, XL_FORMAT_XLSX, &rows, None) .expect("write_sheet must succeed"); - let mut workbook = Workbook::open(target).expect("the written file must open"); + let workbook = Workbook::open(target).expect("the written file must open"); let table = - parse_sheet::(&mut workbook, 1).expect("the written file must parse back"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the written file must parse back"); assert_eq!(table.len(), 2); assert_eq!(table.get(0).expect("row 0"), rows[0]); assert_eq!(table.get(1).expect("row 1"), rows[1]); @@ -472,10 +472,10 @@ fn write_sheet_to_memory_round_trips_the_derive() { let bytes = excelreader::writer::write_sheet_to_memory(XL_FORMAT_XLSX, &rows, None) .expect("write_sheet_to_memory must succeed"); - let mut workbook = + let workbook = Workbook::open_memory(&bytes, XL_FORMAT_XLSX, None).expect("the returned bytes must open"); let table = - parse_sheet::(&mut workbook, 1).expect("the returned bytes must parse back"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the returned bytes must parse back"); assert_eq!(table.len(), 2); assert_eq!(table.get(0).expect("row 0"), rows[0]); assert_eq!(table.get(1).expect("row 1"), rows[1]); @@ -498,8 +498,8 @@ fn the_derive_writes_only_the_primary_column_name() { excelreader::writer::write_sheet(target, XL_FORMAT_XLSX, &rows, None) .expect("write_sheet must succeed"); - let mut workbook = Workbook::open(target).expect("the written file must open"); - parse_sheet::(&mut workbook, 1) + let workbook = Workbook::open(target).expect("the written file must open"); + parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1) .expect("the header must carry the primary name, not the alias"); std::fs::remove_file(&path).ok(); diff --git a/rust/excelreader/tests/writer_handle.rs b/rust/excelreader/tests/writer_handle.rs index 23359a11..9e41c3bc 100644 --- a/rust/excelreader/tests/writer_handle.rs +++ b/rust/excelreader/tests/writer_handle.rs @@ -112,8 +112,8 @@ fn writer_handle_round_trips_through_open_file() { write_full_rows(&mut handle); } - let mut workbook = Workbook::open(&target).expect("the file WriterHandle produced must open"); - let table = parse_sheet::(&mut workbook, 1).expect("the written file must parse back"); + let workbook = Workbook::open(&target).expect("the file WriterHandle produced must open"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the written file must parse back"); assert_eq!(table.len(), 2); let first = table.get(0).expect("row 0 must exist"); @@ -142,13 +142,13 @@ fn writer_handle_to_memory_round_trips() { let bytes = handle.bytes().expect("bytes must succeed"); assert!(!bytes.is_empty()); - let mut workbook = + let workbook = Workbook::open_memory(&bytes, XL_FORMAT_XLSX, None).expect("the bytes bytes() returned must open"); assert_eq!( - workbook.sheet_name().expect("name must read back"), + workbook.sheet_name_at(0).expect("name must read back"), "Planilha1" ); - let table = parse_sheet::(&mut workbook, 1).expect("the returned bytes must parse back"); + let table = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("the returned bytes must parse back"); assert_eq!(table.len(), 2); assert_eq!(table.get(0).expect("row 0").texto, "uma"); From 64eaaabf266c61651038983a54b1d425556250e6 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:49:01 -0300 Subject: [PATCH 30/49] feat(python)!: read through sheets over C ABI v6 Workbook.sheets is a sequence of Sheet objects, looked up by index, slice or name. The read methods move from Workbook to Sheet and each opens its own native cursor. move_to_sheet, sheet_name and the sheets() method are removed. Co-Authored-By: Claude Sonnet 5.5 --- python/benchmarks/bench_polars_engine.py | 2 +- python/benchmarks/bench_read.py | 26 +-- python/benchmarks/bench_write.py | 8 +- python/examples/read_workbook.py | 4 +- python/src/excelreader/__init__.py | 5 +- python/src/excelreader/_native.py | 81 +++---- python/src/excelreader/reader.py | 275 ++++++++++++++--------- python/src/excelreader/types.py | 12 + python/tests/test_native.py | 50 +++-- python/tests/test_reader.py | 252 ++++++++++----------- python/tests/test_sheets.py | 112 +++++++++ python/tests/test_writer.py | 38 ++-- python/tests/test_writer_handle.py | 20 +- 13 files changed, 530 insertions(+), 355 deletions(-) create mode 100644 python/tests/test_sheets.py diff --git a/python/benchmarks/bench_polars_engine.py b/python/benchmarks/bench_polars_engine.py index 35082727..4e1f9b29 100644 --- a/python/benchmarks/bench_polars_engine.py +++ b/python/benchmarks/bench_polars_engine.py @@ -113,7 +113,7 @@ def read_excelreader(path: Path) -> pl.DataFrame: password = PASSWORD if path.stem.startswith("encrypted") else None with open_workbook(path, password=password) as workbook: - return workbook.to_polars(_excelreader_schema()) + return workbook.sheets[0].to_polars(_excelreader_schema()) def read_calamine(path: Path | io.BytesIO) -> pl.DataFrame: diff --git a/python/benchmarks/bench_read.py b/python/benchmarks/bench_read.py index b710333e..7f1eea03 100644 --- a/python/benchmarks/bench_read.py +++ b/python/benchmarks/bench_read.py @@ -42,7 +42,7 @@ def _report(name: str, times: list[float], n: int) -> None: def bench_read_all(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: - all_rows = workbook.read_all() + all_rows = workbook.sheets[0].read_all() row_count = len(all_rows) cell_count = sum(len(row) for row in all_rows) return row_count, cell_count @@ -52,7 +52,7 @@ def bench_rows(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: row_count = 0 cell_count = 0 - for row in workbook.rows(): + for row in workbook.sheets[0].rows(): row_count += 1 cell_count += len(row) return row_count, cell_count @@ -60,7 +60,7 @@ def bench_rows(path: Path) -> tuple[int, int]: def bench_read_all_columnar(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: - sheet = workbook.read_all_columnar() + sheet = workbook.sheets[0].read_all_columnar() return len(sheet.row_offsets) - 1, len(sheet.columns) @@ -88,13 +88,13 @@ def bench_read_all_columnar(path: Path) -> tuple[int, int]: def bench_parse_typed(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: - table = workbook.parse_typed(_FIXTURE_SCHEMA) + table = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA) return table.row_count, table.row_count * len(table.columns) def bench_to_arrow(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: - array = workbook.to_arrow(_FIXTURE_SCHEMA) + array = workbook.sheets[0].to_arrow(_FIXTURE_SCHEMA) return len(array), len(array) * array.type.num_fields @@ -102,7 +102,7 @@ def bench_iter_parse_typed(path: Path) -> tuple[int, int]: """Batched counterpart to bench_parse_typed: one batch resident instead of the whole sheet.""" rows = 0 with excelreader.open_workbook(path, format="xlsb") as workbook: - for table in workbook.iter_parse_typed(_FIXTURE_SCHEMA, batch_size=10000): + for table in workbook.sheets[0].iter_parse_typed(_FIXTURE_SCHEMA, batch_size=10000): rows += table.row_count return rows, rows * len(_FIXTURE_SCHEMA) @@ -110,7 +110,7 @@ def bench_iter_parse_typed(path: Path) -> tuple[int, int]: def bench_record_batch_reader(path: Path) -> tuple[int, int]: rows = 0 with excelreader.open_workbook(path, format="xlsb") as workbook: - for batch in workbook.to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): + for batch in workbook.sheets[0].to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): rows += batch.num_rows return rows, rows * len(_FIXTURE_SCHEMA) @@ -149,7 +149,7 @@ def peak_leg_whole_sheet(path: Path) -> tuple[int, int]: """Whole-sheet Arrow read, sampled once while the full table is live. Returns (rows, peak).""" base = _private_bytes() with excelreader.open_workbook(path, format="xlsb") as workbook: - batch = workbook.to_record_batch(_FIXTURE_SCHEMA) + batch = workbook.sheets[0].to_record_batch(_FIXTURE_SCHEMA) peak = _private_bytes() - base rows = batch.num_rows return rows, peak @@ -161,7 +161,7 @@ def peak_leg_streamed(path: Path) -> tuple[int, int]: rows = 0 peak = 0 with excelreader.open_workbook(path, format="xlsb") as workbook: - for batch in workbook.to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): + for batch in workbook.sheets[0].to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): rows += batch.num_rows peak = max(peak, _private_bytes() - base) del batch @@ -197,8 +197,8 @@ def _measure_peaks(path: Path) -> dict[str, int]: def bench_to_polars(path: Path) -> tuple[int, int]: with excelreader.open_workbook(path, format="xlsb") as workbook: - schema = workbook.infer_schema(sample_size=10) - table = workbook.to_polars(schema) + schema = workbook.sheets[0].infer_schema(sample_size=10) + table = workbook.sheets[0].to_polars(schema) return table.shape[0], table.shape[0] * table.shape[1] def bench_polars(path: Path) -> tuple[int, int]: @@ -213,13 +213,13 @@ def bench_polars(path: Path) -> tuple[int, int]: def bench_csv_parse_typed(path: Path, parallelism: int) -> tuple[int, int]: with excelreader.open_workbook(path, format="csv") as workbook: - table = workbook.parse_typed(_FIXTURE_SCHEMA, parallelism=parallelism) + table = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA, parallelism=parallelism) return table.row_count, table.row_count * len(table.columns) def bench_csv_to_polars(path: Path, parallelism: int) -> tuple[int, int]: with excelreader.open_workbook(path, format="csv") as workbook: - df = workbook.to_polars(_FIXTURE_SCHEMA, parallelism=parallelism) + df = workbook.sheets[0].to_polars(_FIXTURE_SCHEMA, parallelism=parallelism) return df.shape[0], df.shape[0] * df.shape[1] diff --git a/python/benchmarks/bench_write.py b/python/benchmarks/bench_write.py index 546aca96..00c14a90 100644 --- a/python/benchmarks/bench_write.py +++ b/python/benchmarks/bench_write.py @@ -80,7 +80,7 @@ def benchmark_pandas(args: argparse.Namespace, tmp: Path): print() return with excelreader.open_workbook(args.path, format="xlsb") as workbook: - pandas_df = workbook.to_pandas(_FIXTURE_SCHEMA) + pandas_df = workbook.sheets[0].to_pandas(_FIXTURE_SCHEMA) print("write_pandas() [pandas.DataFrame -> Arrow -> pylist -> native columns -> xlsx]:") _time_and_assert( @@ -112,8 +112,8 @@ def benchmark_polars(args: argparse.Namespace, tmp: Path): print() return with excelreader.open_workbook(args.path, format="xlsb") as workbook: - schema = workbook.infer_schema(sample_size=10) - polars_df = workbook.to_polars(schema) + schema = workbook.sheets[0].infer_schema(sample_size=10) + polars_df = workbook.sheets[0].to_polars(schema) print("write_polars() [polars.DataFrame -> Arrow -> pylist -> native columns -> xlsx]:") _time_and_assert( @@ -153,7 +153,7 @@ def main() -> int: print(f"source file: {args.path}") with excelreader.open_workbook(args.path, format="xlsb") as workbook: - table = workbook.parse_typed(_FIXTURE_SCHEMA) + table = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA) print(f"parsed {table.row_count} rows x {len(table.columns)} columns once, before any timing") print() diff --git a/python/examples/read_workbook.py b/python/examples/read_workbook.py index b613595e..d12fa407 100644 --- a/python/examples/read_workbook.py +++ b/python/examples/read_workbook.py @@ -14,8 +14,8 @@ def main() -> int: max_rows = int(sys.argv[2]) if len(sys.argv) > 2 else 10 with open_workbook(path) as workbook: - print(f"sheets={workbook.sheet_count} current={workbook.sheet_name!r} date1904={workbook.is_date1904}") - for index, row in enumerate(workbook.rows()): + print(f"sheets={workbook.sheet_names} date1904={workbook.is_date1904}") + for index, row in enumerate(workbook.sheets[0].rows()): if index >= max_rows: break print(index, [(cell.column, cell.type.name, cell.value) for cell in row]) diff --git a/python/src/excelreader/__init__.py b/python/src/excelreader/__init__.py index 8a667375..06c1a9df 100644 --- a/python/src/excelreader/__init__.py +++ b/python/src/excelreader/__init__.py @@ -1,7 +1,7 @@ """Python bindings for ExcelReader.""" from excelreader.arrow_stream import ArrowStream -from excelreader.reader import Workbook, decode_cell, open_bytes, open_workbook +from excelreader.reader import Sheet, Workbook, decode_cell, open_bytes, open_workbook from excelreader.types import ( Cell, CellType, @@ -12,6 +12,7 @@ OpenOptions, PasswordIncorrectError, PasswordRequiredError, + SheetVisibility, StringColumn, TypedTable, WriteOptions, @@ -41,6 +42,8 @@ "OpenOptions", "PasswordIncorrectError", "PasswordRequiredError", + "Sheet", + "SheetVisibility", "SheetWriter", "StringColumn", "TypedTable", diff --git a/python/src/excelreader/_native.py b/python/src/excelreader/_native.py index a0905564..2fcd78f5 100644 --- a/python/src/excelreader/_native.py +++ b/python/src/excelreader/_native.py @@ -25,7 +25,11 @@ XL_STATUS_PASSWORD_REQUIRED = -6 XL_STATUS_PASSWORD_INCORRECT = -7 -XL_ABI_VERSION = 5 +XL_ABI_VERSION = 6 + +XL_SHEET_VISIBLE = 0 +XL_SHEET_HIDDEN = 1 +XL_SHEET_VERY_HIDDEN = 2 XL_FORMAT_AUTO = 0 XL_FORMAT_XLS = 1 @@ -221,6 +225,8 @@ class ArrowArrayStream(ctypes.Structure): class NativeRows(ctypes.Structure): + """Mirrors xl_rows_decoded.""" + _fields_ = [ ("row_count", ctypes.c_int32), ("rows", ctypes.POINTER(NativeRow)), @@ -399,67 +405,62 @@ def _bind(lib: ctypes.CDLL) -> ctypes.CDLL: p_open_options = ctypes.POINTER(NativeOpenOptions) - lib.xl_open_file.argtypes = [p_bytes, c_int, c_int, pp_void] + p_specs = ctypes.POINTER(NativeColumnSpec) + + lib.xl_open_file.argtypes = [p_bytes, c_int, c_int, p_open_options, pp_void] lib.xl_open_file.restype = c_int - lib.xl_open_file_ex.argtypes = [p_bytes, c_int, c_int, p_open_options, pp_void] - lib.xl_open_file_ex.restype = c_int - lib.xl_open_memory.argtypes = [p_bytes, c_int, c_int, pp_void] + lib.xl_open_memory.argtypes = [p_bytes, c_int, c_int, p_open_options, pp_void] lib.xl_open_memory.restype = c_int - lib.xl_open_memory_ex.argtypes = [p_bytes, c_int, c_int, p_open_options, pp_void] - lib.xl_open_memory_ex.restype = c_int + lib.xl_sheet_visibility_at.argtypes = [p_void, c_int, p_int] + lib.xl_sheet_visibility_at.restype = c_int + lib.xl_sheet_index.argtypes = [p_void, p_bytes, c_int, p_int] + lib.xl_sheet_index.restype = c_int + lib.xl_rows_open.argtypes = [p_void, c_int, pp_void] + lib.xl_rows_open.restype = c_int + lib.xl_rows_close.argtypes = [p_void] + lib.xl_rows_close.restype = c_int + lib.xl_rows_next.argtypes = [p_void, p_bytes, c_int, p_int] + lib.xl_rows_next.restype = c_int + lib.xl_rows_read_all_blob.argtypes = [p_void, p_bytes, c_int, p_int] + lib.xl_rows_read_all_blob.restype = c_int + lib.xl_rows_read_all_decoded.argtypes = [p_void, ctypes.POINTER(NativeRows)] + lib.xl_rows_read_all_decoded.restype = c_int + lib.xl_free_rows.argtypes = [ctypes.POINTER(NativeRows)] + lib.xl_free_rows.restype = None + lib.xl_parse_typed.argtypes = [p_void, c_int, p_specs, c_int, c_int, c_int, ctypes.POINTER(NativeTable)] + lib.xl_parse_typed.restype = c_int + lib.xl_typed_reader_open.argtypes = [p_void, c_int, p_specs, c_int, c_int, ctypes.c_int64, pp_void] + lib.xl_typed_reader_open.restype = c_int + lib.xl_parse_arrow.argtypes = [ + p_void, c_int, p_specs, c_int, c_int, c_int, ctypes.POINTER(ArrowArray), ctypes.POINTER(ArrowSchema), + ] + lib.xl_parse_arrow.restype = c_int + lib.xl_parse_arrow_stream.argtypes = [ + p_void, c_int, p_specs, c_int, c_int, ctypes.c_int64, ctypes.POINTER(ArrowArrayStream), + ] + lib.xl_parse_arrow_stream.restype = c_int + lib.xl_infer_schema.argtypes = [p_void, c_int, c_int, c_int, c_int, ctypes.POINTER(NativeInferredSchema)] + lib.xl_infer_schema.restype = c_int lib.xl_close.argtypes = [p_void] lib.xl_close.restype = c_int lib.xl_sheet_count.argtypes = [p_void, p_int] lib.xl_sheet_count.restype = c_int - lib.xl_sheet_name.argtypes = [p_void, p_bytes, c_int, p_int] - lib.xl_sheet_name.restype = c_int lib.xl_sheet_name_at.argtypes = [p_void, c_int, p_bytes, c_int, p_int] lib.xl_sheet_name_at.restype = c_int - lib.xl_move_to_sheet.argtypes = [p_void, c_int] - lib.xl_move_to_sheet.restype = c_int lib.xl_is_date1904.argtypes = [p_void, p_int] lib.xl_is_date1904.restype = c_int - lib.xl_next_row.argtypes = [p_void, p_bytes, c_int, p_int] - lib.xl_next_row.restype = c_int - lib.xl_read_all_blob.argtypes = [p_void, p_bytes, c_int, p_int] - lib.xl_read_all_blob.restype = c_int lib.xl_last_error.argtypes = [p_bytes, c_int, p_int] lib.xl_last_error.restype = c_int lib.xl_last_error_ptr.argtypes = [p_int] lib.xl_last_error_ptr.restype = ctypes.POINTER(ctypes.c_uint8) - lib.xl_read_all_decoded.argtypes = [p_void, ctypes.POINTER(NativeRows)] - lib.xl_read_all_decoded.restype = c_int - lib.xl_free_rows.argtypes = [ctypes.POINTER(NativeRows)] - lib.xl_free_rows.restype = None - lib.xl_parse_typed.argtypes = [p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, ctypes.POINTER(NativeTable)] - lib.xl_parse_typed.restype = c_int lib.xl_free_table.argtypes = [ctypes.POINTER(NativeTable)] lib.xl_free_table.restype = None - lib.xl_infer_schema.argtypes = [p_void, c_int, c_int, ctypes.POINTER(NativeInferredSchema)] - lib.xl_infer_schema.restype = c_int - lib.xl_infer_schema_ex.argtypes = [p_void, c_int, c_int, c_int, ctypes.POINTER(NativeInferredSchema)] - lib.xl_infer_schema_ex.restype = c_int lib.xl_free_schema.argtypes = [ctypes.POINTER(NativeInferredSchema)] lib.xl_free_schema.restype = None - lib.xl_parse_arrow.argtypes = [p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, ctypes.POINTER(ArrowArray), ctypes.POINTER(ArrowSchema)] - lib.xl_parse_arrow.restype = c_int - lib.xl_parse_typed_ex.argtypes = [p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, c_int, ctypes.POINTER(NativeTable)] - lib.xl_parse_typed_ex.restype = c_int - lib.xl_parse_arrow_ex.argtypes = [p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, c_int, ctypes.POINTER(ArrowArray), ctypes.POINTER(ArrowSchema)] - lib.xl_parse_arrow_ex.restype = c_int - lib.xl_typed_reader_open.argtypes = [ - p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, ctypes.c_int64, pp_void, - ] - lib.xl_typed_reader_open.restype = c_int lib.xl_typed_reader_next.argtypes = [p_void, ctypes.POINTER(NativeTable)] lib.xl_typed_reader_next.restype = c_int lib.xl_typed_reader_close.argtypes = [p_void] lib.xl_typed_reader_close.restype = None - lib.xl_parse_arrow_stream.argtypes = [ - p_void, ctypes.POINTER(NativeColumnSpec), c_int, c_int, ctypes.c_int64, - ctypes.POINTER(ArrowArrayStream), - ] - lib.xl_parse_arrow_stream.restype = c_int lib.xl_write_typed.argtypes = [ p_bytes, c_int, c_int, ctypes.POINTER(NativeColumnSpec), ctypes.POINTER(NativeTable), ctypes.POINTER(NativeWriteOptions), diff --git a/python/src/excelreader/reader.py b/python/src/excelreader/reader.py index a4e65d7d..0d81c97d 100644 --- a/python/src/excelreader/reader.py +++ b/python/src/excelreader/reader.py @@ -3,6 +3,7 @@ from __future__ import annotations import ctypes +import operator import struct from array import array from collections.abc import Iterator, Sequence @@ -24,6 +25,7 @@ OpenOptions, PasswordIncorrectError, PasswordRequiredError, + SheetVisibility, StringColumn, TypedTable, ) @@ -53,9 +55,9 @@ def _check(status: int) -> None: if status == _native.XL_OK: return if status == _native.XL_INVALID_HANDLE: - raise ExcelReaderError("workbook is closed or the handle is invalid") + raise ExcelReaderError(_last_error() or "workbook is closed or the handle is invalid") if status == _native.XL_INVALID_ARGUMENT: - raise ExcelReaderError("invalid argument passed to the native library") + raise ExcelReaderError(_last_error() or "invalid argument passed to the native library") if status == _native.XL_STATUS_PASSWORD_REQUIRED: raise PasswordRequiredError(_last_error() or "the workbook is encrypted and no password was supplied") if status == _native.XL_STATUS_PASSWORD_INCORRECT: @@ -74,8 +76,23 @@ def _resolve_format(name: str | None, path: Path | None) -> int: return _native.XL_FORMAT_AUTO +def _fill_buffer(fn: object, *args: object, initial: int) -> tuple[bytes, int]: + length = ctypes.c_int32() + buffer = ctypes.create_string_buffer(initial) + status = fn(*args, buffer, len(buffer), ctypes.byref(length)) + if status == _native.XL_BUFFER_TOO_SMALL: + buffer = ctypes.create_string_buffer(length.value) + status = fn(*args, buffer, len(buffer), ctypes.byref(length)) + _check(status) + return buffer.raw, length.value + + class Workbook: - """A read cursor over one workbook. Not thread-safe; use one instance per thread.""" + """An open workbook. Read it through `sheets`. + + Safe to share between threads: every read goes through a `Sheet` and opens its own native + cursor, so reads on one workbook never disturb one another. + """ def __init__(self, handle: ctypes.c_void_p) -> None: self._lib = _native.load_library() @@ -92,35 +109,19 @@ def sheet_count(self) -> int: _check(self._lib.xl_sheet_count(self._require_handle(), ctypes.byref(count))) return count.value - def _fill_buffer(self, fn: object, *args: object, initial: int) -> tuple[bytes, int]: - length = ctypes.c_int32() - buffer = ctypes.create_string_buffer(initial) - status = fn(*args, buffer, len(buffer), ctypes.byref(length)) - if status == _native.XL_BUFFER_TOO_SMALL: - buffer = ctypes.create_string_buffer(length.value) - status = fn(*args, buffer, len(buffer), ctypes.byref(length)) - _check(status) - return buffer.raw, length.value - - @property - def sheet_name(self) -> str: - raw, length = self._fill_buffer(self._lib.xl_sheet_name, self._require_handle(), initial=256) - return raw[:length].decode("utf-8") - def sheet_name_at(self, index: int) -> str: - """Name of the sheet at `index`, without changing the current sheet or disturbing row enumeration.""" - raw, length = self._fill_buffer(self._lib.xl_sheet_name_at, self._require_handle(), index, initial=256) + raw, length = _fill_buffer(self._lib.xl_sheet_name_at, self._require_handle(), index, initial=256) return raw[:length].decode("utf-8") @property def sheet_names(self) -> list[str]: - """Every sheet's name, in order. Does not change the current sheet or disturb row enumeration.""" - return [name for _, name in self.sheets()] + """Every sheet's name, in order.""" + return [self.sheet_name_at(index) for index in range(self.sheet_count)] - def sheets(self) -> Iterator[tuple[int, str]]: - """(index, name) for every sheet, in order. Does not change the current sheet or disturb row enumeration.""" - for index in range(self.sheet_count): - yield index, self.sheet_name_at(index) + @property + def sheets(self) -> Sheets: + """The workbook's sheets: `len()`, iteration, and lookup by index, slice or name.""" + return Sheets(self) @property def is_date1904(self) -> bool: @@ -128,58 +129,150 @@ def is_date1904(self) -> bool: _check(self._lib.xl_is_date1904(self._require_handle(), ctypes.byref(flag))) return flag.value != 0 - def move_to_sheet(self, index: int) -> None: - """Selects a sheet and restarts row enumeration from its first row.""" - _check(self._lib.xl_move_to_sheet(self._require_handle(), index)) + def close(self) -> None: + """Closes the workbook. Row generators and record-batch readers already opened keep working.""" + if self._handle is None: + return + handle, self._handle = self._handle, None + _check(self._lib.xl_close(handle)) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_exc_info: object) -> None: + self.close() + + def __del__(self) -> None: + try: + self.close() + except Exception: # noqa: BLE001, S110 + pass + + +class Sheets(Sequence["Sheet"]): + """A workbook's sheets, in order. Listing them opens nothing.""" + + def __init__(self, workbook: Workbook) -> None: + self._workbook = workbook + + def __len__(self) -> int: + return self._workbook.sheet_count + + def __getitem__(self, key: int | str | slice) -> Sheet | list[Sheet]: # type: ignore[override] + workbook = self._workbook + if isinstance(key, str): + encoded = key.encode("utf-8") + index = ctypes.c_int32() + _check(workbook._lib.xl_sheet_index(workbook._require_handle(), encoded, len(encoded), ctypes.byref(index))) + if index.value < 0: + raise KeyError(key) + return Sheet(workbook, index.value) + count = len(self) + if isinstance(key, slice): + return [Sheet(workbook, index) for index in range(*key.indices(count))] + index = operator.index(key) + if index < 0: + index += count + if not 0 <= index < count: + raise IndexError(f"sheet index {key} is out of range; the workbook has {count} sheets") + return Sheet(workbook, index) + + +class Sheet: + """One sheet of a `Workbook`: the workbook and an index. Holds no native resource. + + Every read opens its own cursor, so several reads may be in flight on one sheet or on several, + on this thread or on others. + """ + + def __init__(self, workbook: Workbook, index: int) -> None: + self._workbook = workbook + self._lib = workbook._lib + self._index = index + + def __repr__(self) -> str: + return f"Sheet(index={self._index})" + + @property + def index(self) -> int: + """Zero-based position in the workbook.""" + return self._index + + @property + def name(self) -> str: + return self._workbook.sheet_name_at(self._index) + + @property + def visibility(self) -> SheetVisibility: + value = ctypes.c_int32() + _check( + self._lib.xl_sheet_visibility_at(self._workbook._require_handle(), self._index, ctypes.byref(value)) + ) + return SheetVisibility(value.value) + + def _open_rows(self) -> ctypes.c_void_p: + cursor = ctypes.c_void_p() + _check(self._lib.xl_rows_open(self._workbook._require_handle(), self._index, ctypes.byref(cursor))) + return cursor + + def _close_rows(self, cursor: ctypes.c_void_p) -> None: + self._lib.xl_rows_close(cursor) def rows(self) -> Iterator[list[Cell]]: - written = ctypes.c_int32() - capacity = _INITIAL_ROW_BUFFER - buffer = ctypes.create_string_buffer(capacity) - while True: - handle = self._require_handle() - status = self._lib.xl_next_row(handle, buffer, capacity, ctypes.byref(written)) - if status == _native.XL_EOF: - return - if status == _native.XL_BUFFER_TOO_SMALL: - capacity = written.value - buffer = ctypes.create_string_buffer(capacity) - continue - _check(status) - yield _decode_row(buffer.raw, written.value) + """This sheet's rows, from the first. Each call is an independent pass over the sheet.""" + cursor = self._open_rows() + try: + written = ctypes.c_int32() + capacity = _INITIAL_ROW_BUFFER + buffer = ctypes.create_string_buffer(capacity) + while True: + status = self._lib.xl_rows_next(cursor, buffer, capacity, ctypes.byref(written)) + if status == _native.XL_EOF: + return + if status == _native.XL_BUFFER_TOO_SMALL: + capacity = written.value + buffer = ctypes.create_string_buffer(capacity) + continue + _check(status) + yield _decode_row(buffer.raw, written.value) + finally: + self._close_rows(cursor) def read_all(self) -> list[list[Cell]]: - """Materializes every remaining row of the current sheet in one native call. + """Materializes every row of this sheet in one native call. Builds one `Cell`/`str` object per cell — for a large sheet, `read_all_columnar()` is substantially faster because it never does that. """ - handle = self._require_handle() - rows = _native.NativeRows() - _check(self._lib.xl_read_all_decoded(handle, ctypes.byref(rows))) + cursor = self._open_rows() try: - return [_decode_native_row(rows.rows[index]) for index in range(rows.row_count)] + rows = _native.NativeRows() + _check(self._lib.xl_rows_read_all_decoded(cursor, ctypes.byref(rows))) + try: + return [_decode_native_row(rows.rows[index]) for index in range(rows.row_count)] + finally: + self._lib.xl_free_rows(ctypes.byref(rows)) finally: - self._lib.xl_free_rows(ctypes.byref(rows)) + self._close_rows(cursor) def read_all_columnar(self) -> ColumnarSheet: - """Materializes every remaining row of the current sheet as parallel flat arrays. + """Materializes every row of this sheet as parallel flat arrays. The fast path for large sheets: unlike `read_all()`, this never constructs a `Cell`/`str` per cell. Use `decode_cell()` to materialize one cell on demand. """ - raw, written = self._fill_buffer( - self._lib.xl_read_all_blob, self._require_handle(), initial=_INITIAL_ALL_ROWS_BUFFER - ) + cursor = self._open_rows() + try: + raw, written = _fill_buffer(self._lib.xl_rows_read_all_blob, cursor, initial=_INITIAL_ALL_ROWS_BUFFER) + finally: + self._close_rows(cursor) return _decode_columnar(raw, written) def parse_typed(self, schema: Sequence[ColumnSpec], header_row: int = 1, parallelism: int = 1) -> TypedTable: - """Reads the whole current sheet into typed columns, converting values on the native side. + """Reads this whole sheet into typed columns, converting values on the native side. By far the fastest path in this library: unlike every other read method, no cell is ever - formatted to text, so a large sheet costs a fraction of `read_all_columnar()`. It reads the - sheet from its first row regardless of how far `rows()` has advanced, and does not disturb - that cursor. + formatted to text, so a large sheet costs a fraction of `read_all_columnar()`. `header_row` is a 1-based row number whose values name the columns (that row is skipped and never yielded as data); 0 means the sheet has no header, in which case every spec must @@ -190,10 +283,10 @@ def parse_typed(self, schema: Sequence[ColumnSpec], header_row: int = 1, paralle The table is identical either way; a parallel read holds every partition's columns until they are merged, so its peak memory is higher. """ - handle = self._require_handle() + handle = self._workbook._require_handle() specs = _build_specs(schema) table = _native.NativeTable() - _check(self._lib.xl_parse_typed_ex(handle, specs, len(specs), header_row, parallelism, ctypes.byref(table))) + _check(self._lib.xl_parse_typed(handle, self._index, specs, len(specs), header_row, parallelism, ctypes.byref(table))) try: return _decode_table(schema, table) finally: @@ -205,19 +298,18 @@ def iter_parse_typed( """`parse_typed()` a batch at a time, yielding one `TypedTable` per batch. `batch_size` is rows per batch; 0 means one unbounded batch, exactly what `parse_typed()` - does. Any other read on this workbook — `parse_typed()`, `rows()`, `move_to_sheet()`, - `close()` — invalidates the generator, whose next batch then raises. Finish it, or - `.close()` it, first. + does. The generator owns its position in the sheet and keeps working after the workbook is + closed. Being a generator, it opens nothing until the first iteration, so a bad `batch_size` is not reported until then; `to_record_batch_reader()` raises immediately instead. """ - handle = self._require_handle() + handle = self._workbook._require_handle() specs = _build_specs(schema) reader = ctypes.c_void_p() _check( self._lib.xl_typed_reader_open( - handle, specs, len(specs), header_row, batch_size, ctypes.byref(reader) + handle, self._index, specs, len(specs), header_row, batch_size, ctypes.byref(reader) ) ) try: @@ -250,11 +342,11 @@ def to_arrow(self, schema: Sequence[ColumnSpec], header_row: int = 1, parallelis "parse_typed(), which returns the same data with no third-party dependency." ) from None - handle = self._require_handle() + handle = self._workbook._require_handle() specs = _build_specs(schema) array = _native.ArrowArray() arrow_schema = _native.ArrowSchema() - _check(self._lib.xl_parse_arrow_ex(handle, specs, len(specs), header_row, parallelism, ctypes.byref(array), ctypes.byref(arrow_schema))) + _check(self._lib.xl_parse_arrow(handle, self._index, specs, len(specs), header_row, parallelism, ctypes.byref(array), ctypes.byref(arrow_schema))) return pyarrow.Array._import_from_c(ctypes.addressof(array), ctypes.addressof(arrow_schema)) def to_record_batch(self, schema: Sequence[ColumnSpec], header_row: int = 1, parallelism: int = 1) -> object: @@ -274,8 +366,6 @@ def to_record_batch_reader( Peak memory is one batch rather than one sheet. Requires pyarrow. - The stream borrows this workbook's row cursor under the same rules as `iter_parse_typed()`: - one chunked read at a time, and any other read on this workbook invalidates the stream. pyarrow owns the stream once it is imported — exhausting or dropping the reader is what closes the underlying read. """ @@ -288,12 +378,12 @@ def to_record_batch_reader( "third-party dependency." ) from None - handle = self._require_handle() + handle = self._workbook._require_handle() specs = _build_specs(schema) stream = _native.ArrowArrayStream() _check( self._lib.xl_parse_arrow_stream( - handle, specs, len(specs), header_row, batch_size, ctypes.byref(stream) + handle, self._index, specs, len(specs), header_row, batch_size, ctypes.byref(stream) ) ) return pyarrow.RecordBatchReader._import_from_c(ctypes.addressof(stream)) @@ -304,18 +394,17 @@ def to_arrow_stream( """The same read as `to_record_batch_reader()`, as a single-use Arrow PyCapsule stream. Needs no third-party package to create; pass it to any consumer of `__arrow_c_stream__` - (`polars.DataFrame(stream)`, `pyarrow.RecordBatchReader.from_stream(stream)`, ...). It - borrows this workbook's row cursor under the same rules as `iter_parse_typed()`. + (`polars.DataFrame(stream)`, `pyarrow.RecordBatchReader.from_stream(stream)`, ...). """ - handle = self._require_handle() + handle = self._workbook._require_handle() specs = _build_specs(schema) stream = _native.ArrowArrayStream() _check( self._lib.xl_parse_arrow_stream( - handle, specs, len(specs), header_row, batch_size, ctypes.byref(stream) + handle, self._index, specs, len(specs), header_row, batch_size, ctypes.byref(stream) ) ) - return ArrowStream(stream, self) + return ArrowStream(stream, self._workbook) def iter_pandas( self, schema: Sequence[ColumnSpec], header_row: int = 1, batch_size: int = 10000 @@ -406,36 +495,17 @@ def infer_schema(self, header_row: int = 1, sample_size: int = 100, parse_text: This is a guess over a sample, not a guarantee — a column that looks like `ColumnType.I64` in the sample can still hold a fractional value further down the sheet, which `parse_typed()` - would then reject unless the spec is `nullable`. Reads from the sheet's first row regardless of - how far `rows()` has advanced, and does not disturb that cursor — same as `parse_typed()`. + would then reject unless the spec is `nullable`. """ - handle = self._require_handle() + handle = self._workbook._require_handle() schema = _native.NativeInferredSchema() flags = _native.XL_INFER_PARSE_TEXT if parse_text else 0 - _check(self._lib.xl_infer_schema_ex(handle, header_row, sample_size, flags, ctypes.byref(schema))) + _check(self._lib.xl_infer_schema(handle, self._index, header_row, sample_size, flags, ctypes.byref(schema))) try: return _decode_inferred_schema(schema) finally: self._lib.xl_free_schema(ctypes.byref(schema)) - def close(self) -> None: - if self._handle is None: - return - handle, self._handle = self._handle, None - _check(self._lib.xl_close(handle)) - - def __enter__(self) -> Self: - return self - - def __exit__(self, *_exc_info: object) -> None: - self.close() - - def __del__(self) -> None: - try: - self.close() - except Exception: # noqa: BLE001, S110 - pass - def _decode_row(blob: bytes, length: int) -> list[Cell]: count = struct.unpack_from(" Cell: def _raw_options( options: OpenOptions | None, password: str | None ) -> tuple[object, _native.NativeOpenOptions | None, ctypes.Array[ctypes.c_char] | None]: - """Builds the argument to pass as `xl_open_*_ex`'s `options` parameter, plus the two Python + """Builds the argument to pass as `xl_open_*`'s `options` parameter, plus the two Python objects that back it and must outlive the native call. - A NULL options pointer means "every library default" on the native side — identical to calling - the non-_ex entry point — so both paths can go through xl_open_*_ex and there is only one call - site to keep correct; that NULL case only applies when neither `options` nor `password` was given. + A NULL options pointer means "every library default" on the native side, which is what neither + `options` nor `password` being given produces. Returns `(raw_options_arg, raw, password_buffer)`. `raw` must be kept alive (referenced) until after the native call returns, since `raw_options_arg` points into it; `password_buffer` must be @@ -643,7 +712,7 @@ def open_workbook( lib = _native.load_library() raw_options, _raw, _password_buffer = _raw_options(options, password) _check( - lib.xl_open_file_ex( + lib.xl_open_file( encoded, len(encoded), _resolve_format(format, resolved), raw_options, ctypes.byref(handle) ) ) @@ -662,7 +731,7 @@ def open_bytes( lib = _native.load_library() raw_options, _raw, _password_buffer = _raw_options(options, password) _check( - lib.xl_open_memory_ex( + lib.xl_open_memory( data, len(data), _resolve_format(format, None), raw_options, ctypes.byref(handle) ) ) diff --git a/python/src/excelreader/types.py b/python/src/excelreader/types.py index 2aa8bce3..288c09ab 100644 --- a/python/src/excelreader/types.py +++ b/python/src/excelreader/types.py @@ -17,6 +17,18 @@ class CellType(IntEnum): ERROR = 6 +class SheetVisibility(IntEnum): + """How a sheet is shown in the application's tab bar. + + Mirrors XL_SHEET_* in src/ExcelReader.Native/include/excelreader.h — the values are ABI, do not + renumber them. + """ + + VISIBLE = 0 + HIDDEN = 1 + VERY_HIDDEN = 2 + + class Cell(NamedTuple): """One cell. `value` is the raw text as stored, so DATE cells are Excel serial numbers.""" diff --git a/python/tests/test_native.py b/python/tests/test_native.py index 7cf4455f..735934f1 100644 --- a/python/tests/test_native.py +++ b/python/tests/test_native.py @@ -21,20 +21,21 @@ def test_exported_functions_are_present(): lib = _native.load_library() for name in ( "xl_open_file", - "xl_open_file_ex", "xl_open_memory", - "xl_open_memory_ex", "xl_close", "xl_sheet_count", - "xl_sheet_name", "xl_sheet_name_at", - "xl_move_to_sheet", + "xl_sheet_visibility_at", + "xl_sheet_index", "xl_is_date1904", - "xl_next_row", - "xl_read_all_blob", - "xl_read_all_decoded", + "xl_rows_open", + "xl_rows_close", + "xl_rows_next", + "xl_rows_read_all_blob", + "xl_rows_read_all_decoded", "xl_free_rows", "xl_parse_typed", + "xl_infer_schema", "xl_free_table", "xl_parse_arrow", "xl_typed_reader_open", @@ -58,7 +59,7 @@ def test_last_error_two_call_form_still_works(): lib = _native.load_library() path = str(REPO_ROOT / "nonexistent-workbook-for-error-test.xlsx").encode("utf-8") handle = ctypes.c_void_p() - status = lib.xl_open_file(path, len(path), _native.XL_FORMAT_XLSX, ctypes.byref(handle)) + status = lib.xl_open_file(path, len(path), _native.XL_FORMAT_XLSX, None, ctypes.byref(handle)) assert status == _native.XL_ERROR length = ctypes.c_int32() @@ -71,31 +72,31 @@ def test_last_error_two_call_form_still_works(): assert buffer.raw[: length.value] -def test_open_file_ex_with_default_options_opens_like_open_file(xlsx_path): +def test_open_file_with_default_options_opens(xlsx_path): lib = _native.load_library() path = str(xlsx_path).encode("utf-8") options = _native.default_open_options() handle = ctypes.c_void_p() - status = lib.xl_open_file_ex(path, len(path), _native.XL_FORMAT_XLSX, ctypes.byref(options), ctypes.byref(handle)) + status = lib.xl_open_file(path, len(path), _native.XL_FORMAT_XLSX, ctypes.byref(options), ctypes.byref(handle)) assert status == _native.XL_OK assert handle.value assert lib.xl_close(handle) == _native.XL_OK -def test_open_file_ex_with_null_options_pointer_behaves_like_open_file(xlsx_path): +def test_open_file_with_a_null_options_pointer_opens(xlsx_path): lib = _native.load_library() path = str(xlsx_path).encode("utf-8") handle = ctypes.c_void_p() - status = lib.xl_open_file_ex(path, len(path), _native.XL_FORMAT_XLSX, None, ctypes.byref(handle)) + status = lib.xl_open_file(path, len(path), _native.XL_FORMAT_XLSX, None, ctypes.byref(handle)) assert status == _native.XL_OK assert lib.xl_close(handle) == _native.XL_OK -def test_open_file_ex_applies_a_csv_delimiter_override(tmp_path): +def test_open_file_applies_a_csv_delimiter_override(tmp_path): lib = _native.load_library() csv_file = tmp_path / "semicolons.csv" csv_file.write_text("name;qty\nwidget;7\n", encoding="utf-8") @@ -104,24 +105,27 @@ def test_open_file_ex_applies_a_csv_delimiter_override(tmp_path): options = _native.default_open_options() options.csv_delimiter = ord(";") handle = ctypes.c_void_p() - assert lib.xl_open_file_ex(path, len(path), _native.XL_FORMAT_CSV, ctypes.byref(options), ctypes.byref(handle)) == _native.XL_OK + assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, ctypes.byref(options), ctypes.byref(handle)) == _native.XL_OK + cursor = ctypes.c_void_p() + assert lib.xl_rows_open(handle, 0, ctypes.byref(cursor)) == _native.XL_OK buffer = ctypes.create_string_buffer(4096) written = ctypes.c_int32() - assert lib.xl_next_row(handle, buffer, len(buffer), ctypes.byref(written)) == _native.XL_OK + assert lib.xl_rows_next(cursor, buffer, len(buffer), ctypes.byref(written)) == _native.XL_OK assert b"name" in buffer.raw[: written.value] assert b"qty" in buffer.raw[: written.value] + lib.xl_rows_close(cursor) lib.xl_close(handle) -def test_open_file_ex_rejects_an_unrecognized_struct_size(xlsx_path): +def test_open_file_rejects_an_unrecognized_struct_size(xlsx_path): lib = _native.load_library() path = str(xlsx_path).encode("utf-8") options = _native.default_open_options() options.struct_size = 1 handle = ctypes.c_void_p() - status = lib.xl_open_file_ex(path, len(path), _native.XL_FORMAT_XLSX, ctypes.byref(options), ctypes.byref(handle)) + status = lib.xl_open_file(path, len(path), _native.XL_FORMAT_XLSX, ctypes.byref(options), ctypes.byref(handle)) assert status == _native.XL_INVALID_ARGUMENT @@ -132,14 +136,14 @@ def test_parse_typed_returns_typed_columns_by_name(tmp_path): csv_file.write_text("name,qty\nwidget,3\ngadget,7\n", encoding="utf-8") path = str(csv_file).encode("utf-8") handle = ctypes.c_void_p() - assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, ctypes.byref(handle)) == _native.XL_OK + assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, None, ctypes.byref(handle)) == _native.XL_OK specs = (_native.NativeColumnSpec * 2)( _native.column_spec_by_name("name", _native.XL_T_STRING), _native.column_spec_by_name("qty", _native.XL_T_I64), ) table = _native.NativeTable() - status = lib.xl_parse_typed(handle, specs, len(specs), 1, ctypes.byref(table)) + status = lib.xl_parse_typed(handle, 0, specs, len(specs), 1, 1, ctypes.byref(table)) assert status == _native.XL_OK assert table.row_count == 2 assert table.column_count == 2 @@ -163,14 +167,14 @@ def test_parse_typed_resolves_the_first_alias_present_in_the_header_row(tmp_path csv_file.write_text("name,qty\nwidget,3\ngadget,7\n", encoding="utf-8") path = str(csv_file).encode("utf-8") handle = ctypes.c_void_p() - assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, ctypes.byref(handle)) == _native.XL_OK + assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, None, ctypes.byref(handle)) == _native.XL_OK specs = (_native.NativeColumnSpec * 2)( _native.column_spec_by_names(["does-not-exist", "name"], _native.XL_T_STRING), _native.column_spec_by_name("qty", _native.XL_T_I64), ) table = _native.NativeTable() - status = lib.xl_parse_typed(handle, specs, len(specs), 1, ctypes.byref(table)) + status = lib.xl_parse_typed(handle, 0, specs, len(specs), 1, 1, ctypes.byref(table)) assert status == _native.XL_OK assert table.row_count == 2 @@ -184,7 +188,7 @@ def test_parse_arrow_returns_a_struct_array_with_a_matching_schema(tmp_path): csv_file.write_text("name,qty\nwidget,3\ngadget,7\n", encoding="utf-8") path = str(csv_file).encode("utf-8") handle = ctypes.c_void_p() - assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, ctypes.byref(handle)) == _native.XL_OK + assert lib.xl_open_file(path, len(path), _native.XL_FORMAT_CSV, None, ctypes.byref(handle)) == _native.XL_OK specs = (_native.NativeColumnSpec * 2)( _native.column_spec_by_name("name", _native.XL_T_STRING), @@ -192,7 +196,7 @@ def test_parse_arrow_returns_a_struct_array_with_a_matching_schema(tmp_path): ) array = _native.ArrowArray() schema = _native.ArrowSchema() - status = lib.xl_parse_arrow(handle, specs, len(specs), 1, ctypes.byref(array), ctypes.byref(schema)) + status = lib.xl_parse_arrow(handle, 0, specs, len(specs), 1, 1, ctypes.byref(array), ctypes.byref(schema)) assert status == _native.XL_OK assert schema.format == b"+s" diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index cf55119a..76530123 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -23,7 +23,7 @@ def test_reads_the_xlsx_header_row(xlsx_path): with open_workbook(xlsx_path) as workbook: - header = next(workbook.rows()) + header = next(workbook.sheets[0].rows()) assert len(header) == 18 assert header[0] == Cell(column=0, type=CellType.STRING, value="Coluna1") @@ -32,14 +32,14 @@ def test_reads_the_xlsx_header_row(xlsx_path): def test_reads_every_xlsx_row(xlsx_path): with open_workbook(xlsx_path) as workbook: - rows = list(workbook.rows()) + rows = list(workbook.sheets[0].rows()) assert len(rows) == 101 def test_reports_xlsx_cell_types(xlsx_path): with open_workbook(xlsx_path) as workbook: - rows = workbook.rows() + rows = workbook.sheets[0].rows() next(rows) first_data_row = next(rows) @@ -50,7 +50,7 @@ def test_reports_xlsx_cell_types(xlsx_path): def test_reads_csv_when_the_extension_is_csv(csv_path): with open_workbook(csv_path) as workbook: - rows = list(workbook.rows()) + rows = list(workbook.sheets[0].rows()) assert [cell.value for cell in rows[0]] == ["name", "qty"] assert [cell.value for cell in rows[2]] == ["gadget", "9"] @@ -58,14 +58,14 @@ def test_reads_csv_when_the_extension_is_csv(csv_path): def test_reads_xlsb(xlsb_path): with open_workbook(xlsb_path) as workbook: - first = next(workbook.rows()) + first = next(workbook.sheets[0].rows()) assert len(first) > 0 def test_reads_xls(xls_path): with open_workbook(xls_path) as workbook: - rows = workbook.rows() + rows = workbook.sheets[0].rows() sampled = [next(rows) for _ in range(10)] assert all(len(row) > 0 for row in sampled) @@ -73,7 +73,7 @@ def test_reads_xls(xls_path): def test_reads_from_bytes(xlsx_path): with open_bytes(xlsx_path.read_bytes()) as workbook: - header = next(workbook.rows()) + header = next(workbook.sheets[0].rows()) assert header[0].value == "Coluna1" @@ -81,21 +81,21 @@ def test_reads_from_bytes(xlsx_path): def test_exposes_sheet_metadata(xlsx_path): with open_workbook(xlsx_path) as workbook: assert workbook.sheet_count >= 1 - assert workbook.sheet_name + assert workbook.sheets[0].name assert workbook.is_date1904 is False -def test_sheet_names_matches_sheet_count_and_current_name(xlsx_path): +def test_sheet_names_matches_sheet_count_and_the_sheets(xlsx_path): with open_workbook(xlsx_path) as workbook: names = workbook.sheet_names - assert len(names) == workbook.sheet_count - assert names[0] == workbook.sheet_name - assert list(workbook.sheets()) == list(enumerate(names)) + assert len(names) == workbook.sheet_count == len(workbook.sheets) + assert names[0] == workbook.sheets[0].name + assert [(sheet.index, sheet.name) for sheet in workbook.sheets] == list(enumerate(names)) def test_sheet_names_does_not_disturb_row_enumeration(xlsx_path): with open_workbook(xlsx_path) as workbook: - rows = workbook.rows() + rows = workbook.sheets[0].rows() first = next(rows) second = next(rows) @@ -107,15 +107,6 @@ def test_sheet_names_does_not_disturb_row_enumeration(xlsx_path): assert second != third -def test_move_to_sheet_restarts_enumeration(xlsx_path): - with open_workbook(xlsx_path) as workbook: - first = next(workbook.rows()) - workbook.move_to_sheet(0) - again = next(workbook.rows()) - - assert first == again - - def test_missing_file_raises(tmp_path): with pytest.raises(ExcelReaderError): open_workbook(tmp_path / "nope.xlsx") @@ -135,22 +126,12 @@ def test_close_is_idempotent(xlsx_path): workbook.close() -def test_rows_iterator_raises_after_close_mid_iteration(xlsx_path): - workbook = open_workbook(xlsx_path) - rows = workbook.rows() - next(rows) - workbook.close() - - with pytest.raises(ExcelReaderError): - next(rows) - - def test_dropping_a_workbook_without_close_still_releases_the_file(xlsx_path, tmp_path): copy_path = tmp_path / "dropped.xlsx" shutil.copyfile(xlsx_path, copy_path) workbook = open_workbook(copy_path) - next(workbook.rows()) + next(workbook.sheets[0].rows()) del workbook gc.collect() @@ -161,7 +142,7 @@ def test_as_date_converts_the_serial_value(xlsx_path): from datetime import date with open_workbook(xlsx_path) as workbook: - rows = workbook.rows() + rows = workbook.sheets[0].rows() next(rows) first_data_row = next(rows) date1904 = workbook.is_date1904 @@ -171,7 +152,7 @@ def test_as_date_converts_the_serial_value(xlsx_path): def test_as_date_returns_none_for_non_date_cells(xlsx_path): with open_workbook(xlsx_path) as workbook: - rows = workbook.rows() + rows = workbook.sheets[0].rows() next(rows) first_data_row = next(rows) @@ -180,18 +161,20 @@ def test_as_date_returns_none_for_non_date_cells(xlsx_path): def test_read_all_matches_row_by_row_iteration(xlsx_path): with open_workbook(xlsx_path) as workbook: - all_rows = list(workbook.rows()) + all_rows = list(workbook.sheets[0].rows()) with open_workbook(xlsx_path) as workbook: - bulk_rows = workbook.read_all() + bulk_rows = workbook.sheets[0].read_all() assert bulk_rows == all_rows -def test_read_all_returns_empty_list_at_end_of_sheet(xlsx_path): +def test_read_all_reads_the_whole_sheet_every_time(xlsx_path): with open_workbook(xlsx_path) as workbook: - workbook.read_all() - assert workbook.read_all() == [] + sheet = workbook.sheets[0] + first = sheet.read_all() + assert first + assert sheet.read_all() == first def _columnar_to_rows(sheet) -> list[list[Cell]]: @@ -207,10 +190,10 @@ def test_read_all_columnar_matches_read_all(fixture_name, request): path = request.getfixturevalue(fixture_name) with open_workbook(path) as workbook: - expected = workbook.read_all() + expected = workbook.sheets[0].read_all() with open_workbook(path) as workbook: - sheet = workbook.read_all_columnar() + sheet = workbook.sheets[0].read_all_columnar() assert _columnar_to_rows(sheet) == expected @@ -219,35 +202,33 @@ def test_read_all_columnar_without_numpy(xlsx_path, monkeypatch): monkeypatch.setattr(reader_module, "_numpy", None) with open_workbook(xlsx_path) as workbook: - sheet = workbook.read_all_columnar() + sheet = workbook.sheets[0].read_all_columnar() from array import array assert isinstance(sheet.columns, array) with open_workbook(xlsx_path) as workbook: - assert _columnar_to_rows(sheet) == workbook.read_all() + assert _columnar_to_rows(sheet) == workbook.sheets[0].read_all() def test_read_all_columnar_grows_the_buffer(xlsx_path, monkeypatch): monkeypatch.setattr(reader_module, "_INITIAL_ALL_ROWS_BUFFER", 4) with open_workbook(xlsx_path) as workbook: - grown = workbook.read_all_columnar() + grown = workbook.sheets[0].read_all_columnar() with open_workbook(xlsx_path) as workbook: - expected = workbook.read_all() + expected = workbook.sheets[0].read_all() assert _columnar_to_rows(grown) == expected -def test_read_all_columnar_on_empty_sheet(xlsx_path): +def test_read_all_columnar_reads_the_whole_sheet_every_time(xlsx_path): with open_workbook(xlsx_path) as workbook: - workbook.read_all_columnar() - sheet = workbook.read_all_columnar() - - assert list(sheet.row_offsets) == [0] - assert len(sheet.columns) == 0 - assert sheet.values == b"" + sheet = workbook.sheets[0] + first = sheet.read_all_columnar() + assert len(first.columns) > 0 + assert _columnar_to_rows(sheet.read_all_columnar()) == _columnar_to_rows(first) @@ -277,7 +258,7 @@ def typed_csv(tmp_path): def test_parse_typed_returns_every_column_type(typed_csv): with open_workbook(typed_csv) as workbook: - table = workbook.parse_typed(_TYPED_SCHEMA) + table = workbook.sheets[0].parse_typed(_TYPED_SCHEMA) assert table.row_count == 2 assert table.names == ["name", "qty", "price", "flag", "day", "clock", "stamp"] @@ -293,7 +274,7 @@ def test_parse_typed_returns_every_column_type(typed_csv): def test_parse_typed_resolves_columns_by_index_when_name_is_none(typed_csv): with open_workbook(typed_csv) as workbook: - table = workbook.parse_typed([ColumnSpec(ColumnType.I64, index=1)]) + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, index=1)]) assert list(table.columns[0]) == [3, 7] assert table.names == ["1"] @@ -301,7 +282,7 @@ def test_parse_typed_resolves_columns_by_index_when_name_is_none(typed_csv): def test_parse_typed_string_column_supports_len_and_indexing(typed_csv): with open_workbook(typed_csv) as workbook: - table = workbook.parse_typed([ColumnSpec(ColumnType.STRING, name="name")]) + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.STRING, name="name")]) column = table.columns[0] assert len(column) == 2 @@ -311,7 +292,7 @@ def test_parse_typed_string_column_supports_len_and_indexing(typed_csv): def test_parse_typed_reports_no_validity_bitmap_when_nothing_is_null(typed_csv): with open_workbook(typed_csv) as workbook: - table = workbook.parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) assert table.validity == [None] @@ -321,7 +302,7 @@ def test_parse_typed_builds_a_validity_bitmap_for_a_nullable_column(tmp_path): path.write_text("qty\n3\n\n9\n", encoding="utf-8") with open_workbook(path) as workbook: - table = workbook.parse_typed([ColumnSpec(ColumnType.I64, name="qty", nullable=True)]) + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="qty", nullable=True)]) assert table.row_count == 3 assert list(table.columns[0]) == [3, 0, 9] @@ -333,23 +314,23 @@ def test_parse_typed_raises_when_a_non_nullable_column_fails_to_convert(tmp_path path.write_text("qty\n3\nnot-a-number\n", encoding="utf-8") with open_workbook(path) as workbook, pytest.raises(ExcelReaderError): - workbook.parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) + workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) def test_parse_typed_raises_for_an_unknown_column_name(typed_csv): with open_workbook(typed_csv) as workbook, pytest.raises(ExcelReaderError): - workbook.parse_typed([ColumnSpec(ColumnType.I64, name="nope")]) + workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="nope")]) def test_parse_typed_rejects_an_empty_schema(typed_csv): with open_workbook(typed_csv) as workbook, pytest.raises(ValueError): - workbook.parse_typed([]) + workbook.sheets[0].parse_typed([]) def test_parse_typed_reads_the_whole_sheet_regardless_of_the_row_cursor(typed_csv): with open_workbook(typed_csv) as workbook: - next(workbook.rows()) - table = workbook.parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) + next(workbook.sheets[0].rows()) + table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) assert table.row_count == 2 @@ -358,7 +339,7 @@ def test_to_arrow_returns_a_struct_array_matching_parse_typed(typed_csv): pa = pytest.importorskip("pyarrow") with open_workbook(typed_csv) as workbook: - array = workbook.to_arrow(_TYPED_SCHEMA) + array = workbook.sheets[0].to_arrow(_TYPED_SCHEMA) assert isinstance(array, pa.StructArray) assert len(array) == 2 @@ -375,7 +356,7 @@ def test_to_arrow_survives_the_workbook_being_closed(typed_csv): pytest.importorskip("pyarrow") with open_workbook(typed_csv) as workbook: - array = workbook.to_arrow([ColumnSpec(ColumnType.I64, name="qty")]) + array = workbook.sheets[0].to_arrow([ColumnSpec(ColumnType.I64, name="qty")]) gc.collect() assert array.field(0).to_pylist() == [3, 7] @@ -385,14 +366,14 @@ def test_to_arrow_raises_for_an_unknown_column_name(typed_csv): pytest.importorskip("pyarrow") with open_workbook(typed_csv) as workbook, pytest.raises(ExcelReaderError): - workbook.to_arrow([ColumnSpec(ColumnType.I64, name="nope")]) + workbook.sheets[0].to_arrow([ColumnSpec(ColumnType.I64, name="nope")]) def test_to_record_batch_returns_a_column_named_batch_matching_parse_typed(typed_csv): pa = pytest.importorskip("pyarrow") with open_workbook(typed_csv) as workbook: - batch = workbook.to_record_batch(_TYPED_SCHEMA) + batch = workbook.sheets[0].to_record_batch(_TYPED_SCHEMA) assert isinstance(batch, pa.RecordBatch) assert batch.schema.names == ["name", "qty", "price", "flag", "day", "clock", "stamp"] @@ -404,7 +385,7 @@ def test_to_pandas_returns_a_dataframe_matching_parse_typed(typed_csv): pd = pytest.importorskip("pandas") with open_workbook(typed_csv) as workbook: - df = workbook.to_pandas(_TYPED_SCHEMA) + df = workbook.sheets[0].to_pandas(_TYPED_SCHEMA) assert isinstance(df, pd.DataFrame) assert list(df.columns) == ["name", "qty", "price", "flag", "day", "clock", "stamp"] @@ -416,7 +397,7 @@ def test_to_polars_returns_a_dataframe_matching_parse_typed(typed_csv): pl = pytest.importorskip("polars") with open_workbook(typed_csv) as workbook: - df = workbook.to_polars(_TYPED_SCHEMA) + df = workbook.sheets[0].to_polars(_TYPED_SCHEMA) assert isinstance(df, pl.DataFrame) assert df.columns == ["name", "qty", "price", "flag", "day", "clock", "stamp"] @@ -428,7 +409,7 @@ def test_to_polars_raises_for_an_unknown_column_name(typed_csv): pytest.importorskip("polars") with open_workbook(typed_csv) as workbook, pytest.raises(ExcelReaderError): - workbook.to_polars([ColumnSpec(ColumnType.I64, name="nope")]) + workbook.sheets[0].to_polars([ColumnSpec(ColumnType.I64, name="nope")]) def test_open_options_reaches_the_csv_reader(tmp_path): @@ -436,22 +417,22 @@ def test_open_options_reaches_the_csv_reader(tmp_path): path.write_text("name;qty;price\nwidget;3;9.99\n", encoding="utf-8") with open_workbook(path, format="csv") as workbook: - assert [cell.value for cell in next(workbook.rows())] == ["name;qty;price"] + assert [cell.value for cell in next(workbook.sheets[0].rows())] == ["name;qty;price"] with open_workbook(path, format="csv", options=OpenOptions(csv_delimiter=ord(";"))) as workbook: - assert [cell.value for cell in next(workbook.rows())] == ["name", "qty", "price"] + assert [cell.value for cell in next(workbook.sheets[0].rows())] == ["name", "qty", "price"] def test_open_options_apply_to_open_bytes_too(tmp_path): data = b"name;qty\nwidget;3\n" with open_bytes(data, format="csv", options=OpenOptions(csv_delimiter=ord(";"))) as workbook: - assert [cell.value for cell in next(workbook.rows())] == ["name", "qty"] + assert [cell.value for cell in next(workbook.sheets[0].rows())] == ["name", "qty"] def test_open_options_default_to_the_library_defaults(xlsx_path): with open_workbook(xlsx_path) as plain, open_workbook(xlsx_path, options=OpenOptions()) as explicit: - assert [c.value for c in next(plain.rows())] == [c.value for c in next(explicit.rows())] + assert [c.value for c in next(plain.sheets[0].rows())] == [c.value for c in next(explicit.sheets[0].rows())] def test_open_options_rejects_an_out_of_range_value(csv_path): @@ -464,10 +445,10 @@ def test_open_options_limit_actually_aborts_an_oversized_read(tmp_path): path.write_text("value\n" + ("x" * 200_000) + "\n", encoding="utf-8") with open_workbook(path, format="csv") as workbook: - assert len(list(workbook.rows())[1][0].value) == 200_000 + assert len(list(workbook.sheets[0].rows())[1][0].value) == 200_000 with pytest.raises(ExcelReaderError), open_workbook(path, format="csv", options=OpenOptions(csv_max_cell_bytes=100_000)) as workbook: - list(workbook.rows()) + list(workbook.sheets[0].rows()) @@ -489,9 +470,9 @@ def test_reports_password_incorrect_with_wrong_password(): def test_encrypted_rows_match_plaintext(): with open_workbook(ENCRYPTED / "agile-aes256-sha512.xlsx", password="hunter2") as enc: - encrypted_rows = enc.read_all() + encrypted_rows = enc.sheets[0].read_all() with open_workbook(ENCRYPTED / "agile-aes256-sha512.plain.xlsx") as plain: - assert plain.read_all() == encrypted_rows + assert plain.sheets[0].read_all() == encrypted_rows def test_password_is_not_in_repr(): @@ -523,10 +504,10 @@ def _batch_rows(table): @pytest.mark.parametrize("batch_size", [1, 7, 8, 9, 1000, 0]) def test_iter_parse_typed_matches_parse_typed(batched_csv, batch_size): with open_workbook(batched_csv) as workbook: - expected = _batch_rows(workbook.parse_typed(_BATCH_SCHEMA)) + expected = _batch_rows(workbook.sheets[0].parse_typed(_BATCH_SCHEMA)) with open_workbook(batched_csv) as workbook: - batches = list(workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=batch_size)) + batches = list(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=batch_size)) assert [row for batch in batches for row in _batch_rows(batch)] == expected @@ -540,34 +521,36 @@ def test_iter_parse_typed_matches_parse_typed(batched_csv, batch_size): def test_iter_parse_typed_rejects_a_negative_batch_size(batched_csv): with open_workbook(batched_csv) as workbook: with pytest.raises(ExcelReaderError): - next(workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=-1)) + next(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=-1)) -def test_a_second_reader_on_one_workbook_is_rejected(batched_csv): +def test_two_readers_on_one_sheet_each_read_every_row(batched_csv): with open_workbook(batched_csv) as workbook: - first = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) - next(first) - second = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) - with pytest.raises(ExcelReaderError): - next(second) - first.close() + sheet = workbook.sheets[0] + first = sheet.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + second = sheet.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + assert next(first).row_count == 4 + assert next(second).row_count == 4 + assert sum(batch.row_count for batch in first) == 46 + assert sum(batch.row_count for batch in second) == 46 -def test_a_foreign_read_latches_the_readers_error(batched_csv): + +def test_a_whole_sheet_read_leaves_an_open_reader_alone(batched_csv): with open_workbook(batched_csv) as workbook: - batches = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + sheet = workbook.sheets[0] + batches = sheet.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) next(batches) - workbook.parse_typed(_BATCH_SCHEMA) - with pytest.raises(ExcelReaderError): - next(batches) + assert sheet.parse_typed(_BATCH_SCHEMA).row_count == 50 + assert sum(batch.row_count for batch in batches) == 46 def test_abandoning_the_generator_closes_the_reader(batched_csv): with open_workbook(batched_csv) as workbook: - batches = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + batches = workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4) next(batches) batches.close() - again = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + again = workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4) assert next(again).row_count == 4 again.close() @@ -577,10 +560,10 @@ def test_abandoning_the_generator_closes_the_reader(batched_csv): def test_to_record_batch_reader_matches_to_record_batch(batched_csv): pytest.importorskip("pyarrow") with open_workbook(batched_csv) as workbook: - expected = workbook.to_record_batch(_BATCH_SCHEMA) + expected = workbook.sheets[0].to_record_batch(_BATCH_SCHEMA) with open_workbook(batched_csv) as workbook: - reader = workbook.to_record_batch_reader(_BATCH_SCHEMA, batch_size=8) + reader = workbook.sheets[0].to_record_batch_reader(_BATCH_SCHEMA, batch_size=8) assert reader.schema == expected.schema batches = list(reader) @@ -593,13 +576,13 @@ def test_to_record_batch_reader_rejects_a_negative_batch_size(batched_csv): pytest.importorskip("pyarrow") with open_workbook(batched_csv) as workbook: with pytest.raises(ExcelReaderError): - workbook.to_record_batch_reader(_BATCH_SCHEMA, batch_size=-1) + workbook.sheets[0].to_record_batch_reader(_BATCH_SCHEMA, batch_size=-1) def test_arrow_stream_builds_a_polars_dataframe_in_batches(batched_csv): pl = pytest.importorskip("polars") with open_workbook(batched_csv) as workbook: - df = pl.DataFrame(workbook.to_arrow_stream(_BATCH_SCHEMA, batch_size=8)) + df = pl.DataFrame(workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=8)) assert df.columns == ["name", "qty"] assert df["qty"].to_list() == list(range(50)) @@ -607,7 +590,7 @@ def test_arrow_stream_builds_a_polars_dataframe_in_batches(batched_csv): def test_arrow_stream_is_single_use(batched_csv): with open_workbook(batched_csv) as workbook: - stream = workbook.to_arrow_stream(_BATCH_SCHEMA) + stream = workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA) stream.__arrow_c_stream__() with pytest.raises(ExcelReaderError): stream.__arrow_c_stream__() @@ -615,61 +598,52 @@ def test_arrow_stream_is_single_use(batched_csv): def test_an_unconsumed_arrow_stream_releases_the_read(batched_csv): with open_workbook(batched_csv) as workbook: - workbook.to_arrow_stream(_BATCH_SCHEMA, batch_size=4) + workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4) gc.collect() - assert next(workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 + assert next(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 def test_a_dropped_capsule_releases_the_read(batched_csv): with open_workbook(batched_csv) as workbook: - workbook.to_arrow_stream(_BATCH_SCHEMA, batch_size=4).__arrow_c_stream__() + workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4).__arrow_c_stream__() gc.collect() - assert next(workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 + assert next(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 def test_arrow_stream_rejects_a_negative_batch_size(batched_csv): with open_workbook(batched_csv) as workbook: with pytest.raises(ExcelReaderError): - workbook.to_arrow_stream(_BATCH_SCHEMA, batch_size=-1) + workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=-1) -def test_a_stream_is_rejected_while_a_reader_is_live(batched_csv): +def test_a_stream_and_a_reader_on_one_sheet_each_read_every_row(batched_csv): pytest.importorskip("pyarrow") with open_workbook(batched_csv) as workbook: - reader = workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) - next(reader) - with pytest.raises(ExcelReaderError): - workbook.to_record_batch_reader(_BATCH_SCHEMA, batch_size=4) - reader.close() - + sheet = workbook.sheets[0] + reader = sheet.iter_parse_typed(_BATCH_SCHEMA, batch_size=4) + next(reader) + stream = sheet.to_record_batch_reader(_BATCH_SCHEMA, batch_size=4) -def test_a_reader_is_rejected_while_a_stream_is_live(batched_csv): - pytest.importorskip("pyarrow") - with open_workbook(batched_csv) as workbook: - stream = workbook.to_record_batch_reader(_BATCH_SCHEMA, batch_size=4) # noqa: F841 (kept alive on purpose) - with pytest.raises(ExcelReaderError): - next(workbook.iter_parse_typed(_BATCH_SCHEMA, batch_size=4)) + assert sum(batch.row_count for batch in reader) == 46 + assert sum(batch.num_rows for batch in stream) == 50 -def test_a_foreign_read_latches_the_streams_error(batched_csv): +def test_a_whole_sheet_read_leaves_an_open_stream_alone(batched_csv): pytest.importorskip("pyarrow") with open_workbook(batched_csv) as workbook: - reader = workbook.to_record_batch_reader(_BATCH_SCHEMA, batch_size=4) + sheet = workbook.sheets[0] + reader = sheet.to_record_batch_reader(_BATCH_SCHEMA, batch_size=4) reader.read_next_batch() - workbook.parse_typed(_BATCH_SCHEMA) + assert sheet.parse_typed(_BATCH_SCHEMA).row_count == 50 - with pytest.raises(OSError) as first: - reader.read_next_batch() - with pytest.raises(OSError) as second: - reader.read_next_batch() - assert str(first.value) == str(second.value) + assert sum(batch.num_rows for batch in reader) == 46 def test_iter_pandas_yields_one_frame_per_batch(batched_csv): pytest.importorskip("pyarrow") pytest.importorskip("pandas") with open_workbook(batched_csv) as workbook: - frames = list(workbook.iter_pandas(_BATCH_SCHEMA, batch_size=8)) + frames = list(workbook.sheets[0].iter_pandas(_BATCH_SCHEMA, batch_size=8)) assert len(frames) == 7 assert sum(len(frame) for frame in frames) == 50 @@ -678,7 +652,7 @@ def test_iter_polars_yields_one_frame_per_batch(batched_csv): pytest.importorskip("pyarrow") pytest.importorskip("polars") with open_workbook(batched_csv) as workbook: - frames = list(workbook.iter_polars(_BATCH_SCHEMA, batch_size=8)) + frames = list(workbook.sheets[0].iter_polars(_BATCH_SCHEMA, batch_size=8)) assert len(frames) == 7 assert sum(frame.height for frame in frames) == 50 @@ -686,7 +660,7 @@ def test_iter_polars_yields_one_frame_per_batch(batched_csv): def test_to_polars_stays_chunked(batched_csv): polars = pytest.importorskip("polars") with open_workbook(batched_csv) as workbook: - frame = workbook.to_polars(_BATCH_SCHEMA, batch_size=8) + frame = workbook.sheets[0].to_polars(_BATCH_SCHEMA, batch_size=8) assert isinstance(frame, polars.DataFrame) assert frame.height == 50 assert frame.n_chunks() > 1, "to_polars must not rechunk the streamed batches" @@ -696,7 +670,7 @@ def test_to_pandas_still_reads_the_whole_sheet(batched_csv): pytest.importorskip("pyarrow") pytest.importorskip("pandas") with open_workbook(batched_csv) as workbook: - frame = workbook.to_pandas(_BATCH_SCHEMA, batch_size=8) + frame = workbook.sheets[0].to_pandas(_BATCH_SCHEMA, batch_size=8) assert len(frame) == 50 assert list(frame.columns) == ["name", "qty"] @@ -706,7 +680,7 @@ def test_to_pandas_splits_blocks_per_column(batched_csv): pytest.importorskip("pandas") schema = [*_BATCH_SCHEMA, ColumnSpec(ColumnType.I64, index=1)] with open_workbook(batched_csv) as workbook: - frame = workbook.to_pandas(schema, batch_size=8) + frame = workbook.sheets[0].to_pandas(schema, batch_size=8) assert frame._mgr.nblocks == 3 @@ -722,8 +696,8 @@ def test_to_pandas_splits_blocks_per_column(batched_csv): def test_parse_typed_in_parallel_matches_the_sequential_table(): with open_workbook(_FIXTURE_CSV) as workbook: - sequential = workbook.parse_typed(_FIXTURE_SCHEMA) - parallel = workbook.parse_typed(_FIXTURE_SCHEMA, parallelism=0) + sequential = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA) + parallel = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA, parallelism=0) assert parallel.row_count == sequential.row_count == 65_535 for expected, actual in zip(sequential.columns, parallel.columns): @@ -734,21 +708,21 @@ def test_parse_typed_in_parallel_matches_the_sequential_table(): def test_to_polars_in_parallel_matches_the_streamed_frame(): pytest.importorskip("polars") with open_workbook(_FIXTURE_CSV) as workbook: - streamed = workbook.to_polars(_FIXTURE_SCHEMA) - parallel = workbook.to_polars(_FIXTURE_SCHEMA, parallelism=0) + streamed = workbook.sheets[0].to_polars(_FIXTURE_SCHEMA) + parallel = workbook.sheets[0].to_polars(_FIXTURE_SCHEMA, parallelism=0) assert parallel.equals(streamed) def test_parse_typed_rejects_a_negative_parallelism(typed_csv): with open_workbook(typed_csv) as workbook, pytest.raises(ExcelReaderError): - workbook.parse_typed(_TYPED_SCHEMA, parallelism=-1) + workbook.sheets[0].parse_typed(_TYPED_SCHEMA, parallelism=-1) def test_infer_schema_parse_text_types_a_csv(): with open_workbook(_FIXTURE_CSV) as workbook: - schema = workbook.infer_schema(parse_text=True) - table = workbook.parse_typed(schema) + schema = workbook.sheets[0].infer_schema(parse_text=True) + table = workbook.sheets[0].parse_typed(schema) T = ColumnType assert [spec.type for spec in schema] == [T.STRING] * 5 + [T.DATE, T.I64, T.DATE, T.I64] + [T.F64] * 5 @@ -757,6 +731,6 @@ def test_infer_schema_parse_text_types_a_csv(): def test_infer_schema_without_parse_text_keeps_csv_columns_as_strings(): with open_workbook(_FIXTURE_CSV) as workbook: - schema = workbook.infer_schema() + schema = workbook.sheets[0].infer_schema() assert {spec.type for spec in schema} == {ColumnType.STRING} diff --git a/python/tests/test_sheets.py b/python/tests/test_sheets.py new file mode 100644 index 00000000..ffbd906f --- /dev/null +++ b/python/tests/test_sheets.py @@ -0,0 +1,112 @@ +import gc + +import pytest +from excelreader import ExcelReaderError, Sheet, SheetVisibility, open_bytes, open_writer_to_memory + + +def _two_sheets() -> bytes: + with open_writer_to_memory("xlsx") as writer: + for name, values in (("First", ["a1", "a2"]), ("Second", ["b1", "b2", "b3"])): + writer.start_sheet(name) + for value in values: + writer.write_row([value]) + writer.end_sheet() + return writer.bytes() + + +def _first_column(sheet: Sheet) -> list[str]: + return [row[0].value for row in sheet.rows()] + + +def test_a_sheet_reads_its_own_rows(): + with open_bytes(_two_sheets()) as workbook: + assert _first_column(workbook.sheets[1]) == ["b1", "b2", "b3"] + assert _first_column(workbook.sheets[0]) == ["a1", "a2"] + + +def test_sheets_is_a_sequence_of_sheets(): + with open_bytes(_two_sheets()) as workbook: + sheets = workbook.sheets + + assert len(sheets) == 2 + assert [sheet.name for sheet in sheets] == ["First", "Second"] + assert sheets[-1].index == 1 + assert [sheet.index for sheet in sheets[0:2]] == [0, 1] + assert sheets[0].visibility is SheetVisibility.VISIBLE + + +def test_sheets_looks_a_sheet_up_by_name_without_regard_to_case(): + with open_bytes(_two_sheets()) as workbook: + assert workbook.sheets["Second"].index == 1 + assert workbook.sheets["sEcOnD"].index == 1 + + +@pytest.mark.parametrize(("key", "error"), [(5, IndexError), (-3, IndexError), ("nope", KeyError), (1.0, TypeError)]) +def test_sheets_rejects_a_key_that_names_no_sheet(key, error): + with open_bytes(_two_sheets()) as workbook, pytest.raises(error): + workbook.sheets[key] + + +def test_two_cursors_on_one_sheet_each_return_every_row(): + with open_bytes(_two_sheets()) as workbook: + sheet = workbook.sheets[1] + one = sheet.rows() + two = sheet.rows() + + assert next(one)[0].value == "b1" + assert next(two)[0].value == "b1" + assert [row[0].value for row in one] == ["b2", "b3"] + assert [row[0].value for row in two] == ["b2", "b3"] + + +def test_an_abandoned_rows_generator_closes_its_cursor(monkeypatch): + with open_bytes(_two_sheets()) as workbook: + sheet = workbook.sheets[1] + closed = [] + real_close = workbook._lib.xl_rows_close + monkeypatch.setattr( + type(sheet), "_close_rows", lambda self, cursor: closed.append(real_close(cursor)), raising=True + ) + + rows = sheet.rows() + next(rows) + rows.close() + del rows + gc.collect() + + assert closed == [0] + + +def test_a_sheet_of_a_closed_workbook_raises(): + workbook = open_bytes(_two_sheets()) + sheet = workbook.sheets[0] + workbook.close() + + with pytest.raises(ExcelReaderError): + list(sheet.rows()) + with pytest.raises(ExcelReaderError): + sheet.name + + +def test_rows_opened_before_close_read_to_the_end_after_it(): + workbook = open_bytes(_two_sheets()) + rows = workbook.sheets[1].rows() + assert next(rows)[0].value == "b1" + + workbook.close() + + assert [row[0].value for row in rows] == ["b2", "b3"] + + +def test_read_all_and_parse_typed_read_the_named_sheet(): + from excelreader import ColumnSpec, ColumnType + + with open_bytes(_two_sheets()) as workbook: + second = workbook.sheets[1] + schema = [ColumnSpec(ColumnType.STRING, index=0, nullable=True)] + + assert len(second.read_all()) == 3 + assert len(second.read_all_columnar().row_offsets) == 4 + assert second.parse_typed(schema, header_row=0).row_count == 3 + assert sum(table.row_count for table in second.iter_parse_typed(schema, header_row=0, batch_size=2)) == 3 + assert len(second.infer_schema(header_row=0)) == 1 diff --git a/python/tests/test_writer.py b/python/tests/test_writer.py index bf741956..de2da3dc 100644 --- a/python/tests/test_writer.py +++ b/python/tests/test_writer.py @@ -36,13 +36,13 @@ def source_csv(tmp_path): @pytest.mark.parametrize("extension", ["xlsx", "xlsb", "xls", "csv"]) def test_write_workbook_round_trips_every_format(source_csv, tmp_path, extension): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) out = tmp_path / f"out.{extension}" write_workbook(out, table, _TYPES) with open_workbook(out) as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert result.row_count == 2 assert list(result.columns[0]) == ["widget", "gadget"] @@ -55,13 +55,13 @@ def test_write_workbook_round_trips_a_nullable_column(tmp_path): schema = [ColumnSpec(ColumnType.I64, name="qty", nullable=True)] with open_workbook(source) as workbook: - table = workbook.parse_typed(schema) + table = workbook.sheets[0].parse_typed(schema) out = tmp_path / "out.xlsx" write_workbook(out, table, [spec.type for spec in schema]) with open_workbook(out) as workbook: - result = workbook.parse_typed(schema) + result = workbook.sheets[0].parse_typed(schema) assert result.row_count == 3 assert result.validity[0][0] & 0b111 == 0b101 @@ -69,7 +69,7 @@ def test_write_workbook_round_trips_a_nullable_column(tmp_path): def test_write_workbook_applies_the_sheet_name(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) out = tmp_path / "named.xlsx" write_workbook(out, table, _TYPES, options=WriteOptions(sheet_name="Vendas")) @@ -80,7 +80,7 @@ def test_write_workbook_applies_the_sheet_name(source_csv, tmp_path): def test_write_workbook_rejects_an_unknown_extension(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) with pytest.raises(ValueError, match="format"): write_workbook(tmp_path / "out.parquet", table, _TYPES) @@ -88,7 +88,7 @@ def test_write_workbook_rejects_an_unknown_extension(source_csv, tmp_path): def test_write_workbook_reports_a_native_rejection(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) with pytest.raises(ExcelReaderError): write_workbook(tmp_path / "out.xlsx", table, _TYPES, options=WriteOptions(sheet_name="has/slash")) @@ -102,7 +102,7 @@ def test_write_arrow_round_trips(tmp_path): write_arrow(out, batch) with open_workbook(out) as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert list(result.columns[0]) == ["widget", "gadget"] assert list(result.columns[1]) == [3, 7] @@ -116,7 +116,7 @@ def test_write_pandas_round_trips(tmp_path): write_pandas(out, pd.DataFrame({"name": ["widget", "gadget"], "qty": [3, 7]})) with open_workbook(out) as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert list(result.columns[0]) == ["widget", "gadget"] assert list(result.columns[1]) == [3, 7] @@ -139,7 +139,7 @@ def test_write_pandas_round_trips_a_multi_chunk_frame(tmp_path): write_pandas(out, frame) with open_workbook(out) as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert list(result.columns[0]) == ["widget", "gadget"] assert list(result.columns[1]) == [3, 7] @@ -156,7 +156,7 @@ def test_write_pandas_writes_a_header_only_sheet_for_an_empty_frame(tmp_path): ) with open_workbook(out) as workbook: - rows = workbook.read_all() + rows = workbook.sheets[0].read_all() assert [cell.value for cell in rows[0]] == ["name", "qty"] assert len(rows) == 1 @@ -170,7 +170,7 @@ def test_write_polars_round_trips(tmp_path): write_polars(out, pl.DataFrame({"name": ["widget", "gadget"], "qty": [3, 7]})) with open_workbook(out) as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert list(result.columns[0]) == ["widget", "gadget"] assert list(result.columns[1]) == [3, 7] @@ -187,7 +187,7 @@ def test_write_arrow_rejects_an_unsupported_arrow_type(tmp_path): def test_encrypt_package_round_trips_through_open_workbook(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) plain = tmp_path / "plain.xlsx" write_workbook(plain, table, _TYPES) @@ -196,7 +196,7 @@ def test_encrypt_package_round_trips_through_open_workbook(source_csv, tmp_path) encrypt_package(plain, encrypted, "hunter2") with open_workbook(encrypted, password="hunter2") as workbook: - result = workbook.parse_typed(_SCHEMA) + result = workbook.sheets[0].parse_typed(_SCHEMA) assert list(result.columns[0]) == ["widget", "gadget"] assert list(result.columns[1]) == [3, 7] @@ -204,7 +204,7 @@ def test_encrypt_package_round_trips_through_open_workbook(source_csv, tmp_path) def test_encrypt_package_rejects_the_wrong_password_on_open(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) plain = tmp_path / "plain.xlsx" write_workbook(plain, table, _TYPES) @@ -218,7 +218,7 @@ def test_encrypt_package_rejects_the_wrong_password_on_open(source_csv, tmp_path def test_encrypt_package_rejects_an_empty_password(source_csv, tmp_path): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) plain = tmp_path / "plain.xlsx" write_workbook(plain, table, _TYPES) @@ -229,7 +229,7 @@ def test_encrypt_package_rejects_an_empty_password(source_csv, tmp_path): def _parse_bytes(payload, format, password=None): with open_bytes(payload, format=format, password=password) as workbook: - return workbook.parse_typed(_SCHEMA) + return workbook.sheets[0].parse_typed(_SCHEMA) @pytest.mark.parametrize( @@ -252,7 +252,7 @@ def test_frame_writers_to_bytes_round_trip(writer, frame_module): def test_encrypt_package_bytes_round_trips_without_touching_disk(source_csv): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) encrypted = encrypt_package_bytes(write_workbook_to_bytes(table, _TYPES, format="xlsx"), "hunter2") result = _parse_bytes(encrypted, "xlsx", password="hunter2") @@ -270,7 +270,7 @@ def test_encrypt_package_bytes_rejects_bytes_that_are_not_a_package(): def test_encrypt_package_bytes_rejects_an_empty_password(source_csv): with open_workbook(source_csv) as workbook: - table = workbook.parse_typed(_SCHEMA) + table = workbook.sheets[0].parse_typed(_SCHEMA) with pytest.raises(ExcelReaderError): encrypt_package_bytes(write_workbook_to_bytes(table, _TYPES, format="xlsx"), "") diff --git a/python/tests/test_writer_handle.py b/python/tests/test_writer_handle.py index c159fa34..9f09469a 100644 --- a/python/tests/test_writer_handle.py +++ b/python/tests/test_writer_handle.py @@ -22,7 +22,7 @@ def test_round_trips_typed_cells(tmp_path): writer.end_sheet() with open_workbook(path) as workbook: - rows = [[cell.value for cell in row] for row in workbook.rows()] + rows = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert rows[0] == ["name", "qty"] assert rows[1][0] == "widget" @@ -55,7 +55,7 @@ def test_writes_every_scalar_type(tmp_path): writer.end_sheet() with open_workbook(path) as workbook: - rows = [[cell.value for cell in row] for row in workbook.rows()] + rows = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert rows[0][0] == "s" assert rows[0][1] == "1" @@ -73,7 +73,7 @@ def test_write_str_none_is_a_blank_cell(tmp_path): writer.end_sheet() with open_workbook(path) as workbook: - rows = [[cell.value for cell in row] for row in workbook.rows()] + rows = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert rows[0][-1] == "after" @@ -122,7 +122,7 @@ def test_write_row_infers_every_supported_type(tmp_path): writer.end_sheet() with open_workbook(path) as workbook: - rows = [[(cell.type, cell.value) for cell in row] for row in workbook.rows()] + rows = [[(cell.type, cell.value) for cell in row] for row in workbook.sheets[0].rows()] types = [cell_type for cell_type, _ in rows[0]] values = [value for _, value in rows[0]] @@ -163,7 +163,7 @@ def test_write_row_matches_explicit_calls(tmp_path): def read(path): with open_workbook(path) as workbook: - return [[(cell.type, cell.value) for cell in row] for row in workbook.rows()] + return [[(cell.type, cell.value) for cell in row] for row in workbook.sheets[0].rows()] assert read(sugar) == read(explicit) @@ -182,7 +182,7 @@ def test_in_memory_writer_round_trips(): assert len(payload) > 0 with open_bytes(payload, format="xlsx") as workbook: - rows = [[cell.value for cell in row] for row in workbook.rows()] + rows = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert rows[0] == ["name", "qty"] assert rows[1][0] == "widget" @@ -204,8 +204,8 @@ def test_write_workbook_to_bytes_matches_write_workbook(tmp_path, xlsx_path): from excelreader import open_bytes, write_workbook, write_workbook_to_bytes with open_workbook(xlsx_path) as workbook: - schema = workbook.infer_schema() - table = workbook.parse_typed(schema) + schema = workbook.sheets[0].infer_schema() + table = workbook.sheets[0].parse_typed(schema) types = [spec.type for spec in schema] @@ -214,8 +214,8 @@ def test_write_workbook_to_bytes_matches_write_workbook(tmp_path, xlsx_path): payload = write_workbook_to_bytes(table, types, format="xlsx") with open_workbook(path) as workbook: - from_file = [[cell.value for cell in row] for row in workbook.rows()] + from_file = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] with open_bytes(payload, format="xlsx") as workbook: - from_memory = [[cell.value for cell in row] for row in workbook.rows()] + from_memory = [[cell.value for cell in row] for row in workbook.sheets[0].rows()] assert from_file == from_memory From c6e54d95dba7113af9d5ca51f1552119240d3277 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:50:08 -0300 Subject: [PATCH 31/49] fix(cpp): migrate the package consumer and tighten a rows check Co-Authored-By: Claude Sonnet 5.5 --- cpp/tests/package/main.cpp | 10 ++++++++-- cpp/tests/rows.cpp | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cpp/tests/package/main.cpp b/cpp/tests/package/main.cpp index d739d19e..2aaed760 100644 --- a/cpp/tests/package/main.cpp +++ b/cpp/tests/package/main.cpp @@ -11,9 +11,15 @@ int main() return 1; } - auto cursor = workbook->rows(); + auto cursor = workbook->sheet(0).rows(); + if (!cursor.has_value()) + { + std::fprintf(stderr, "FAIL: the installed package could not open a cursor\n"); + return 1; + } + int rows = 0; - while (cursor.next_row().has_value()) + while (cursor->next_row().has_value()) { ++rows; } diff --git a/cpp/tests/rows.cpp b/cpp/tests/rows.cpp index af692b2e..587cfe85 100644 --- a/cpp/tests/rows.cpp +++ b/cpp/tests/rows.cpp @@ -150,7 +150,7 @@ int main() } auto decoded = workbook->sheet(0).read_all_decoded(); check(decoded.has_value(), "a read after another cursor drained is not an error"); - check(decoded.has_value() && !decoded->empty(), "and it still reads every row, from the start"); + check(decoded.has_value() && decoded->size() == rows.size(), "and it still reads every row, from the start"); } } From 11ead80bdffcc441fdfc22479c8b732468311934 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:51:22 -0300 Subject: [PATCH 32/49] test(rust): read every sheet of one workbook on its own thread Co-Authored-By: Claude Sonnet 5.5 --- rust/excelreader/tests/sheets.rs | 61 ++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/rust/excelreader/tests/sheets.rs b/rust/excelreader/tests/sheets.rs index 08570246..f981bcd3 100644 --- a/rust/excelreader/tests/sheets.rs +++ b/rust/excelreader/tests/sheets.rs @@ -1,6 +1,6 @@ -use excelreader::workbook::{SheetVisibility, Workbook}; +use excelreader::workbook::{Sheet, SheetVisibility, Workbook}; use excelreader::writer_handle::WriterHandle; -use excelreader::{XL_FORMAT_AUTO, XL_FORMAT_XLSX}; +use excelreader::{RowCursor, XL_FORMAT_AUTO, XL_FORMAT_XLSX}; fn two_sheets() -> Workbook { let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); @@ -108,3 +108,60 @@ fn infer_schema_samples_the_named_sheet() { assert_eq!(columns.len(), 1); } + +const PARALLEL_SHEETS: usize = 6; +const PARALLEL_ROWS: usize = 500; + +fn many_sheets() -> Workbook { + let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); + for sheet in 0..PARALLEL_SHEETS { + writer.start_sheet(&format!("sheet{sheet}")).expect("start sheet"); + for row in 0..PARALLEL_ROWS { + writer.start_row().expect("start row"); + writer.write_str(Some(&format!("s{sheet}-r{row}"))).expect("write"); + writer.end_row().expect("end row"); + } + writer.end_sheet().expect("end sheet"); + } + let bytes = writer.bytes().expect("bytes"); + Workbook::open_memory(&bytes, XL_FORMAT_AUTO, None).expect("open") +} + +fn read_sheet(sheet: Sheet<'_>) -> Vec { + first_column(&mut sheet.rows().expect("cursor")) +} + +#[test] +fn workbook_and_sheet_cross_threads_and_cursors_move_between_them() { + fn assert_send_sync() {} + fn assert_send() {} + + assert_send_sync::(); + assert_send_sync::>(); + assert_send::>(); +} + +#[test] +fn every_sheet_read_on_its_own_thread_matches_a_sequential_read() { + let workbook = many_sheets(); + let sheets = workbook.sheets().expect("sheets"); + let sequential: Vec> = sheets.iter().map(|sheet| read_sheet(*sheet)).collect(); + assert_eq!(sequential[3][0], "s3-r0"); + assert_eq!(sequential[3].len(), PARALLEL_ROWS); + + let parallel: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = sheets + .iter() + .chain(sheets.iter()) + .map(|sheet| { + let sheet = *sheet; + scope.spawn(move || read_sheet(sheet)) + }) + .collect(); + handles.into_iter().map(|handle| handle.join().expect("no panic")).collect() + }); + + for (index, rows) in parallel.iter().enumerate() { + assert_eq!(rows, &sequential[index % PARALLEL_SHEETS]); + } +} From f67f5a120ef870c8685bd0fe7a3790ba4c081c83 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:52:30 -0300 Subject: [PATCH 33/49] test(python): make the release tests observe the native close The v5 versions asserted that a fresh reader could start, which proved the workbook's single cursor slot was free. That slot is gone, so a leaked reader or stream passed. They now spy on xl_typed_reader_close and on the Arrow stream release, and the parse_typed test keeps a row cursor alive while it runs. Co-Authored-By: Claude Sonnet 5.5 --- python/tests/test_reader.py | 63 ++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/python/tests/test_reader.py b/python/tests/test_reader.py index 76530123..ad3a6476 100644 --- a/python/tests/test_reader.py +++ b/python/tests/test_reader.py @@ -16,6 +16,7 @@ open_bytes, open_workbook, ) +from excelreader import arrow_stream as arrow_stream_module from excelreader import reader as reader_module ENCRYPTED = Path(__file__).resolve().parents[2] / "tests" / "ExcelReader.Tests" / "data" / "encrypted" @@ -329,10 +330,13 @@ def test_parse_typed_rejects_an_empty_schema(typed_csv): def test_parse_typed_reads_the_whole_sheet_regardless_of_the_row_cursor(typed_csv): with open_workbook(typed_csv) as workbook: - next(workbook.sheets[0].rows()) + rows = workbook.sheets[0].rows() + next(rows) table = workbook.sheets[0].parse_typed([ColumnSpec(ColumnType.I64, name="qty")]) + rest = list(rows) assert table.row_count == 2 + assert len(rest) == 2 def test_to_arrow_returns_a_struct_array_matching_parse_typed(typed_csv): @@ -545,14 +549,24 @@ def test_a_whole_sheet_read_leaves_an_open_reader_alone(batched_csv): assert sum(batch.row_count for batch in batches) == 46 -def test_abandoning_the_generator_closes_the_reader(batched_csv): +def _spy_on_native_close(monkeypatch, lib, name): + closed = [] + real = getattr(lib, name) + monkeypatch.setattr(lib, name, lambda handle: closed.append(handle.value) or real(handle)) + return closed + + +def test_abandoning_the_generator_closes_the_reader(batched_csv, monkeypatch): with open_workbook(batched_csv) as workbook: + closed = _spy_on_native_close(monkeypatch, workbook._lib, "xl_typed_reader_close") batches = workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4) next(batches) - batches.close() - again = workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4) - assert next(again).row_count == 4 - again.close() + assert closed == [] + + batches.close() + + assert len(closed) == 1 + assert closed[0] @@ -596,18 +610,43 @@ def test_arrow_stream_is_single_use(batched_csv): stream.__arrow_c_stream__() -def test_an_unconsumed_arrow_stream_releases_the_read(batched_csv): +def _spy_on_stream_release(monkeypatch): + released = [] + real = arrow_stream_module._release + + def spy(stream): + released.append(bool(stream.release)) + real(stream) + + monkeypatch.setattr(arrow_stream_module, "_release", spy) + return released + + +def test_an_unconsumed_arrow_stream_releases_the_read(batched_csv, monkeypatch): with open_workbook(batched_csv) as workbook: - workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4) + released = _spy_on_stream_release(monkeypatch) + stream = workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4) + assert released == [] + + del stream gc.collect() - assert next(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 + + assert released == [True] -def test_a_dropped_capsule_releases_the_read(batched_csv): +def test_a_dropped_capsule_releases_the_read(batched_csv, monkeypatch): with open_workbook(batched_csv) as workbook: - workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4).__arrow_c_stream__() + released = _spy_on_stream_release(monkeypatch) + stream = workbook.sheets[0].to_arrow_stream(_BATCH_SCHEMA, batch_size=4) + capsule = stream.__arrow_c_stream__() + del stream gc.collect() - assert next(workbook.sheets[0].iter_parse_typed(_BATCH_SCHEMA, batch_size=4)).row_count == 4 + assert released == [] + + del capsule + gc.collect() + + assert released == [True] def test_arrow_stream_rejects_a_negative_batch_size(batched_csv): From 885a32f8e82e86e4505f3b67718dcc50fc2de69b Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:52:47 -0300 Subject: [PATCH 34/49] docs(cpp): show sheets and parallel reads, and the move from v5 Co-Authored-By: Claude Sonnet 5.5 --- cpp/README.md | 64 ++++++++++++++++++++++++++--------- docs/guide/migrating-to-v6.md | 16 +++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/cpp/README.md b/cpp/README.md index 27505fa5..3b286f83 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -91,7 +91,7 @@ template<> struct xl::ExcelMapper { }; auto workbook = xl::Workbook::open("book.xlsx"); -auto table = xl::parse_sheet(*workbook); +auto table = xl::parse_sheet(workbook->sheet(0)); for (const auto& row : *table) { /* ... */ } ``` @@ -99,25 +99,61 @@ for (const auto& row : *table) { /* ... */ } ```cpp for (const auto& name : *workbook->sheet_names()) { /* ... */ } -workbook->move_to_sheet(1); // Guess a schema from the header row plus a sample of the data, before committing to one. -for (const auto& column : *workbook->infer_schema(1, 100)) { +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. } ``` 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, +it is cheap to copy, and every read on it opens its own cursor. `workbook->sheet(i)` makes no native +call; an index outside the workbook is reported by the first read on the returned sheet. + +```cpp +auto workbook = xl::Workbook::open("report.xlsx"); +if (!workbook) { /* workbook.error().message */ } + +auto sheets = workbook->sheets().value(); +for (xl::Sheet sheet : sheets) +{ + std::println("{} {}", sheet.index(), sheet.name().value()); +} + +if (auto totals = workbook->sheet_by_name("Totals").value()) +{ + auto cursor = totals->rows(); + while (auto row = cursor->next_row()) + { + // ... + } +} +``` + +One open workbook can be read from several threads, which loads the shared-string table once: + +```cpp +std::vector threads; +for (xl::Sheet sheet : sheets) +{ + threads.emplace_back([sheet] { auto table = xl::parse_sheet(sheet); /* ... */ }); +} +``` + +A cursor, a typed reader and an Arrow stream are each used by one thread at a time, and keep +working after the workbook is destroyed. A `Sheet` does not: take what you need from it first. + ## Reading rows one at a time -`Workbook::rows()` returns a cursor over the current sheet. Each row borrows a buffer the cursor +`Sheet::rows()` returns a cursor over that sheet. Each row borrows a buffer the cursor reuses, so iterating allocates nothing per row. A clean end of sheet arrives as an error carrying `XL_EOF`: ```cpp auto workbook = xl::Workbook::open("book.xlsx").value(); -auto cursor = workbook.rows(); +auto cursor = workbook.sheet(0).rows().value(); while (auto row = cursor.next_row()) { for (auto cell : *row) @@ -133,7 +169,7 @@ Each row is invalidated by the next `next_row()`. To hold every row at once, use destroyed: ```cpp -auto rows = workbook.read_all_decoded().value(); +auto rows = workbook.sheet(0).read_all_decoded().value(); for (auto row : rows) { std::println("{} cells", row.size()); @@ -149,7 +185,7 @@ delivered a batch at a time: ```cpp auto workbook = xl::Workbook::open("book.xlsx").value(); -auto reader = xl::typed_reader(workbook, 1, 10'000).value(); +auto reader = xl::typed_reader(workbook.sheet(0), 1, 10'000).value(); for (auto &batch : reader) { if (!batch) { break; } @@ -163,11 +199,8 @@ for (auto &batch : reader) per batch — `0` means one unbounded batch, identical to `parse_sheet`; negative is an error. Each batch is independent and outlives the reader. -A workbook serves one chunked read at a time, of either kind (typed or Arrow — see -[Arrow export](#arrow-export) below), and any other read on it while one is live invalidates that -reader: its next call reports a latched error rather than resuming from the moved cursor. C++ cannot -enforce that at compile time the way the Rust binding does — finish or destroy the reader before -starting another read. +Several typed readers and Arrow streams may be open on one workbook at once; each read is +independent. ### Encrypted workbooks @@ -219,7 +252,7 @@ Arrow implementation you already link. #include auto workbook = xl::Workbook::open("book.xlsx"); -auto table = xl::parse_arrow(*workbook); +auto table = xl::parse_arrow(workbook->sheet(0)); // table->array / table->schema are a top-level struct array; both release in ~ArrowTable. ``` @@ -228,7 +261,7 @@ Batched: `xl::arrow_stream` is `xl::parse_arrow` delivered a batch at a ti each batch outlives the stream that produced it: ```cpp -auto stream = xl::arrow_stream(*workbook, 1, 10'000); +auto stream = xl::arrow_stream(workbook->sheet(0), 1, 10'000); while (true) { auto batch = stream->next(); @@ -237,8 +270,7 @@ while (true) } ``` -Same `header_row`/`batch_size` meaning, and the same one-chunked-read-at-a-time rule, as -`xl::typed_reader` above. +Same `header_row`/`batch_size` meaning as `xl::typed_reader` above. ## Writing diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index 89b0528b..529e68f3 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -204,3 +204,19 @@ Behavior changes: - Opening a second typed reader or Arrow stream on a workbook used to fail, and reading the workbook another way used to invalidate an open one. Both now work: each read is independent. - A cursor, typed reader or Arrow stream keeps working after `xl_close` on its workbook. + +### C++ + +The wrapper follows the library: reads move from `xl::Workbook` to `xl::Sheet`. + +| v5 | v6 | +|---|---| +| `workbook.move_to_sheet(i)` then a read | read from `workbook.sheet(i)` | +| `workbook.sheet_name()` | `workbook.sheet(i).name()` or `workbook.sheet_name_at(i)` | +| `workbook.rows()` | `workbook.sheet(0).rows()`, which returns `std::expected` | +| `workbook.read_all_decoded()`, `workbook.infer_schema(h, n)` | the same methods on a `Sheet` | +| `xl::parse_sheet(workbook, h)`, `xl::typed_reader(workbook, h, n)` | the same, taking a `Sheet` | +| `xl::parse_arrow(workbook, h)`, `xl::arrow_stream(workbook, h, n)` | the same, taking a `Sheet` | + +`xl::RowCursor` now owns a native cursor and is move-only. A header built for ABI 5 refuses a v6 +library with an ABI-version error at the first `Workbook::open`. From fea5c2ea597dfcedffaf65378a01f787b9eb4854 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:54:26 -0300 Subject: [PATCH 35/49] docs(cpp): take the cursor's value in the sheet-by-name example Co-Authored-By: Claude Sonnet 5.5 --- cpp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/README.md b/cpp/README.md index 3b286f83..9c713a13 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -124,8 +124,8 @@ for (xl::Sheet sheet : sheets) if (auto totals = workbook->sheet_by_name("Totals").value()) { - auto cursor = totals->rows(); - while (auto row = cursor->next_row()) + auto cursor = totals->rows().value(); + while (auto row = cursor.next_row()) { // ... } From fa719aad69295854fb9581602ea02e7f8f3e1e61 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:54:52 -0300 Subject: [PATCH 36/49] docs(rust): show sheets and parallel reads, and the move from v5 Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/migrating-to-v6.md | 18 ++++++++ rust/excelreader/README.md | 85 ++++++++++++++++++++++------------- 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index 529e68f3..4e2105ee 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -205,6 +205,24 @@ Behavior changes: another way used to invalidate an open one. Both now work: each read is independent. - A cursor, typed reader or Arrow stream keeps working after `xl_close` on its workbook. +### Rust + +The crate follows the library: reads move from `Workbook` to `Sheet`. + +| v5 | v6 | +|---|---| +| `workbook.move_to_sheet(i)?` then a read | `workbook.sheet(i)?` and read from it | +| `workbook.sheet_name()?` | `workbook.sheet(i)?.name()?` or `workbook.sheet_name_at(i)?` | +| `workbook.rows()` | `workbook.sheet(0)?.rows()?` | +| `workbook.read_all_blob()?`, `read_all_decoded()?` | the same methods on a `Sheet` | +| `workbook.typed_chunks::(h, n)?`, `workbook.infer_schema(h, n)?` | the same methods on a `Sheet` | +| `parse_sheet::(&mut workbook, h)?` | `parse_sheet::(workbook.sheet(0)?, h)?` | +| `parse_arrow::(&mut workbook, h)?`, `parse_arrow_stream` | the same, taking a `Sheet` | + +`Workbook` no longer needs to be `mut`, and it is `Send + Sync`. A cursor reads its sheet once from +the top; call `rows()` again for another pass. A crate built for ABI 5 refuses a v6 library with an +ABI-version error at the first `Workbook::open`. + ### C++ The wrapper follows the library: reads move from `xl::Workbook` to `xl::Sheet`. diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index bd636144..c96a928a 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -22,12 +22,12 @@ struct Row { count: u32, } -let mut workbook = Workbook::open("book.xlsx")?; -let table = parse_sheet::(&mut workbook, 1)?; +let workbook = Workbook::open("book.xlsx")?; +let table = parse_sheet::(workbook.sheet(0)?, 1)?; for row in table.iter() { /* ... */ } ``` -`parse_sheet` takes `&mut Workbook` because it consumes the workbook's shared row cursor. +`parse_sheet` takes a `Sheet`, which `Workbook::sheet` hands out (see below). ### Field types @@ -55,18 +55,49 @@ excelreader = { version = "2.1", features = ["chrono"] } ### Sheets and schema inference ```rust -let mut workbook = Workbook::open("book.xlsx")?; +let workbook = Workbook::open("book.xlsx")?; for (index, name) in workbook.sheet_names()?.iter().enumerate() { println!("{index}: {name}"); } -workbook.move_to_sheet(1)?; // Guess a schema from the header row plus a sample of the data, before committing to one. -for column in workbook.infer_schema(1, 100)? { +for column in workbook.sheet(1)?.infer_schema(1, 100)? { println!("{:?} -> type {}", column.name, column.column_type); } ``` +A workbook hands out sheets. A `Sheet` is the workbook and an index: it holds no native +resource, it is `Copy`, and every read on it opens its own cursor. + +```rust +let workbook = Workbook::open("report.xlsx")?; + +for sheet in workbook.sheets()? { + println!("{} {:?}", sheet.name()?, sheet.visibility()?); +} + +let sheet = workbook.sheet_by_name("Totals")?.expect("the workbook has a Totals sheet"); +let mut rows = sheet.rows()?; +while let Some(row) = rows.next_row() { + let row = row?; + // ... +} +``` + +`Workbook` is `Send + Sync`, so sheets can be read in parallel over one open workbook, which +loads the shared-string table once: + +```rust +let tables = std::thread::scope(|scope| { + let handles: Vec<_> = workbook + .sheets()? + .into_iter() + .map(|sheet| scope.spawn(move || sheet.read_all_blob())) + .collect(); + handles.into_iter().map(|handle| handle.join().expect("no panic")).collect::, _>>() +})?; +``` + `Workbook::open_with` takes an explicit format and `OpenOptions`; `Workbook::open_memory` reads from a byte slice. Note that format sniffing does not detect CSV - pass `XL_FORMAT_CSV` explicitly. @@ -121,8 +152,8 @@ excelreader = { version = "2.1", features = ["arrow"] } use excelreader::arrow::parse_arrow; use excelreader::workbook::Workbook; -let mut workbook = Workbook::open("book.xlsx")?; -let batch = parse_arrow::(&mut workbook, 1)?; +let workbook = Workbook::open("book.xlsx")?; +let batch = parse_arrow::(workbook.sheet(0)?, 1)?; println!("{} rows x {} columns", batch.num_rows(), batch.num_columns()); ``` @@ -136,8 +167,8 @@ Batched: `parse_arrow_stream` is `parse_arrow` delivered a batch at a time, retu use excelreader::arrow::parse_arrow_stream; use excelreader::workbook::Workbook; -let mut workbook = Workbook::open("book.xlsx")?; -let mut chunks = parse_arrow_stream::(&mut workbook, 1, 10_000)?; +let workbook = Workbook::open("book.xlsx")?; +let mut chunks = parse_arrow_stream::(workbook.sheet(0)?, 1, 10_000)?; while let Some(batch) = chunks.next() { let Ok(batch) = batch else { break }; // ArrowChunks does not fuse, unlike TypedChunks println!("{} rows", batch.num_rows()); @@ -145,7 +176,7 @@ while let Some(batch) = chunks.next() { # Ok::<(), excelreader::Error>(()) ``` -Same `batch_size`/one-chunked-read-at-a-time semantics as `typed_chunks` above. Unlike `TypedChunks`, +Same `batch_size` semantics as `typed_chunks` below. Unlike `TypedChunks`, `ArrowChunks` does not fuse after an error — it follows arrow-rs's own semantics — so break on the first `Err` rather than continuing the loop. @@ -221,14 +252,14 @@ on the drop for that. ## Reading rows one at a time -`Workbook::rows` returns a cursor over the current sheet. Each row borrows a buffer the cursor +`Sheet::rows` returns a cursor over that sheet. Each row borrows a buffer the cursor reuses, so iterating a sheet allocates nothing per row: ```rust use excelreader::workbook::Workbook; -let mut workbook = Workbook::open("book.xlsx")?; -let mut cursor = workbook.rows(); +let workbook = Workbook::open("book.xlsx")?; +let mut cursor = workbook.sheet(0)?.rows()?; while let Some(row) = cursor.next_row() { let row = row?; for cell in row.iter() { @@ -240,12 +271,12 @@ while let Some(row) = cursor.next_row() { ``` Because each row borrows the cursor's buffer, only one row is alive at a time — the borrow checker -enforces it. To hold every row at once, use `read_all_decoded`, which decodes the whole remaining +enforces it. To hold every row at once, use `read_all_decoded`, which decodes the whole sheet in one native call and keeps it alive until dropped: ```rust -let mut workbook = Workbook::open("book.xlsx")?; -let rows = workbook.read_all_decoded()?; +let workbook = Workbook::open("book.xlsx")?; +let rows = workbook.sheet(0)?.read_all_decoded()?; for row in rows.iter() { println!("{} cells", row.len()); } @@ -257,14 +288,14 @@ native side and never formats a cell to text. ### Chunked reads -For a sheet too large to hold in memory at once, `Workbook::typed_chunks` is `parse_sheet` delivered +For a sheet too large to hold in memory at once, `Sheet::typed_chunks` is `parse_sheet` delivered a batch at a time: ```rust use excelreader::workbook::Workbook; -let mut workbook = Workbook::open("book.xlsx")?; -let chunks = workbook.typed_chunks::(1, 10_000)?; +let workbook = Workbook::open("book.xlsx")?; +let chunks = workbook.sheet(0)?.typed_chunks::(1, 10_000)?; for batch in chunks { let batch = batch?; for row in batch.iter() { /* ... */ } @@ -274,16 +305,10 @@ for batch in chunks { `typed_chunks` returns `TypedChunks<'_, Row>`, an `Iterator, Error>>` that closes the native reader on drop. `batch_size` is rows per batch — `0` means one unbounded -batch, identical to `parse_sheet`; negative is an error. A workbook serves one chunked read, of -either kind (typed or Arrow — see [Arrow export](#arrow-export-arrow-feature) above), at a time; any -other read on it while one is live invalidates that reader, so its next call reports a latched error -rather than resuming from the moved cursor. `TypedChunks` itself ends after yielding an `Err`, so a -`for` loop stops there rather than spinning on it. - -Both `typed_chunks` and `arrow::parse_arrow_stream` take `&mut Workbook` — the same borrow -`parse_sheet` takes — which is why a second chunked read, or a `parse_sheet` call, while one is live -is a compile error here rather than the runtime error the C++ and Python bindings report for the same -mistake. +batch, identical to `parse_sheet`; negative is an error. Chunked reads, of either kind (typed or +Arrow — see [Arrow export](#arrow-export-arrow-feature) above), are independent: several can be live +on one workbook, and other reads do not disturb them. `TypedChunks` ends after yielding an `Err`, so +a `for` loop stops there rather than spinning on it. ## Bounds and panics From bd6d945556928771b4c57bfac2877a0691b25d81 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:55:19 -0300 Subject: [PATCH 37/49] test(python): read every sheet of one workbook on its own thread Co-Authored-By: Claude Haiku 4.5 --- python/tests/test_sheets.py | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/python/tests/test_sheets.py b/python/tests/test_sheets.py index ffbd906f..ea9b0044 100644 --- a/python/tests/test_sheets.py +++ b/python/tests/test_sheets.py @@ -1,4 +1,5 @@ import gc +from concurrent.futures import ThreadPoolExecutor import pytest from excelreader import ExcelReaderError, Sheet, SheetVisibility, open_bytes, open_writer_to_memory @@ -18,6 +19,20 @@ def _first_column(sheet: Sheet) -> list[str]: return [row[0].value for row in sheet.rows()] +_PARALLEL_SHEETS = 6 +_PARALLEL_ROWS = 500 + + +def _many_sheets() -> bytes: + with open_writer_to_memory("xlsx") as writer: + for sheet in range(_PARALLEL_SHEETS): + writer.start_sheet(f"sheet{sheet}") + for row in range(_PARALLEL_ROWS): + writer.write_row([f"s{sheet}-r{row}", sheet * 1_000_000 + row]) + writer.end_sheet() + return writer.bytes() + + def test_a_sheet_reads_its_own_rows(): with open_bytes(_two_sheets()) as workbook: assert _first_column(workbook.sheets[1]) == ["b1", "b2", "b3"] @@ -110,3 +125,44 @@ def test_read_all_and_parse_typed_read_the_named_sheet(): assert second.parse_typed(schema, header_row=0).row_count == 3 assert sum(table.row_count for table in second.iter_parse_typed(schema, header_row=0, batch_size=2)) == 3 assert len(second.infer_schema(header_row=0)) == 1 + + +def test_every_sheet_read_on_its_own_thread_matches_a_sequential_read(): + with open_bytes(_many_sheets()) as workbook: + sheets = list(workbook.sheets) + sequential = [_first_column(sheet) for sheet in sheets] + assert sequential[3][0] == "s3-r0" + assert len(sequential[3]) == _PARALLEL_ROWS + + with ThreadPoolExecutor(max_workers=_PARALLEL_SHEETS * 2) as pool: + parallel = list(pool.map(_first_column, sheets + sheets)) + + for position, rows in enumerate(parallel): + assert rows == sequential[position % _PARALLEL_SHEETS] + + +def test_typed_parses_of_every_sheet_run_on_separate_threads(): + from excelreader import ColumnSpec, ColumnType + + schema = [ColumnSpec(ColumnType.I64, index=1)] + with open_bytes(_many_sheets()) as workbook, ThreadPoolExecutor(max_workers=_PARALLEL_SHEETS) as pool: + tables = list(pool.map(lambda sheet: sheet.parse_typed(schema, header_row=0), workbook.sheets)) + + for sheet, table in enumerate(tables): + assert table.row_count == _PARALLEL_ROWS + assert int(table.columns[0][0]) == sheet * 1_000_000 + + +def test_a_record_batch_reader_outlives_its_workbook(): + pyarrow = pytest.importorskip("pyarrow") + from excelreader import ColumnSpec, ColumnType + + schema = [ColumnSpec(ColumnType.I64, index=1)] + with open_bytes(_many_sheets()) as workbook: + reader = workbook.sheets[2].to_record_batch_reader(schema, header_row=0, batch_size=100) + + table = reader.read_all() + + assert isinstance(table, pyarrow.Table) + assert table.num_rows == _PARALLEL_ROWS + assert table.column(0)[0].as_py() == 2_000_000 From 1c592b702a788839b2898f3fb3812fd61a3fea21 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 00:57:03 -0300 Subject: [PATCH 38/49] docs(python): show sheets and parallel reads, and the move from v5 Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/migrating-to-v6.md | 15 ++++++ python/README.md | 94 ++++++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index 4e2105ee..c1560977 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -223,6 +223,21 @@ The crate follows the library: reads move from `Workbook` to `Sheet`. the top; call `rows()` again for another pass. A crate built for ABI 5 refuses a v6 library with an ABI-version error at the first `Workbook::open`. +### Python + +The package follows the library: reads move from `Workbook` to `Sheet`. + +| v5 | v6 | +|---|---| +| `workbook.move_to_sheet(i)` then a read | read from `workbook.sheets[i]` | +| `workbook.sheet_name` | `workbook.sheets[i].name` | +| `for index, name in workbook.sheets():` | `for sheet in workbook.sheets:` | +| `workbook.rows()`, `read_all()`, `read_all_columnar()` | the same methods on `workbook.sheets[i]` | +| `workbook.parse_typed(...)`, `to_arrow(...)`, `to_polars(...)`, `infer_schema(...)` and the other read methods | the same methods on `workbook.sheets[i]`, same arguments | + +`workbook.sheets["Name"]` finds a sheet by name and raises `KeyError` when there is none. A package +built for ABI 5 refuses a v6 library when it loads it. + ### C++ The wrapper follows the library: reads move from `xl::Workbook` to `xl::Sheet`. diff --git a/python/README.md b/python/README.md index 66db619c..78b5d73e 100644 --- a/python/README.md +++ b/python/README.md @@ -20,12 +20,45 @@ elsewhere, set `EXCELREADER_NATIVE_LIB` to its full path. from excelreader import open_workbook with open_workbook("book.xlsx") as workbook: - print(workbook.sheet_count, workbook.sheet_name) - for row in workbook.rows(): + print(workbook.sheet_count, workbook.sheets[0].name) + for row in workbook.sheets[0].rows(): for cell in row: print(cell.column, cell.type.name, cell.value) ``` +### Sheets + +A workbook hands out sheets. `workbook.sheets` is a sequence: `len()`, iteration, and lookup by +index (negative allowed), slice or name (names match without regard to case; a miss raises +`KeyError`). A `Sheet` is the workbook and an index; it holds no native resource, and every read on +it opens its own cursor. + +```python +import excelreader + +with excelreader.open_workbook("report.xlsx") as workbook: + for sheet in workbook.sheets: + print(sheet.index, sheet.name, sheet.visibility) + + totals = workbook.sheets["Totals"] + for row in totals.rows(): + ... +``` + +One open workbook can be read from several threads, which loads the shared-string table once. +The native calls release the GIL: + +```python +from concurrent.futures import ThreadPoolExecutor + +with excelreader.open_workbook("report.xlsx") as workbook, ThreadPoolExecutor() as pool: + tables = list(pool.map(lambda sheet: sheet.to_arrow(schema), workbook.sheets)) +``` + +`rows()` and `iter_parse_typed()` are generators: the native cursor opens at the first `next()`. A +generator or a `RecordBatchReader` that has already started keeps working after `workbook.close()`; +a generator that has not started raises at its first `next()`. + ### Formats `open_workbook` sniffs XLS/XLSX/XLSB by file signature. CSV has no signature, so it is chosen by the @@ -51,7 +84,7 @@ as_date = cell.as_date(workbook.is_date1904) `rows()` iterates row-by-row; `read_all()` materializes the whole sheet in one call: ```python -all_rows = workbook.read_all() +all_rows = workbook.sheets[0].read_all() ``` This holds every row in memory at once, so prefer `rows()` for very large sheets. @@ -63,10 +96,10 @@ large sheet. `read_all_columnar()` decodes the same data into parallel flat arra per-cell object construction — and is several times faster on large sheets: ```python -sheet = workbook.read_all_columnar() -# sheet.row_offsets[i]:row_offsets[i+1] -> cell indices for row i -# sheet.columns[j] / sheet.types[j] -> cell j's column index / CellType -# sheet.value_offsets[j]:[j+1] -> cell j's byte slice into sheet.values +columnar = workbook.sheets[0].read_all_columnar() +# columnar.row_offsets[i]:row_offsets[i+1] -> cell indices for row i +# columnar.columns[j] / columnar.types[j] -> cell j's column index / CellType +# columnar.value_offsets[j]:[j+1] -> cell j's byte slice into columnar.values ``` Materialize a single cell on demand instead of decoding every value up front: @@ -74,7 +107,7 @@ Materialize a single cell on demand instead of decoding every value up front: ```python from excelreader import decode_cell -first_cell = decode_cell(sheet, 0) +first_cell = decode_cell(columnar, 0) ``` Each array is a stdlib `array.array('i')`, or a NumPy `int32` array if NumPy is installed @@ -92,7 +125,7 @@ natively, straight into typed column buffers. On a 65,536 × 14 sheet it is ~8× from excelreader import ColumnSpec, ColumnType with open_workbook("sales.xlsb") as workbook: - table = workbook.parse_typed([ + table = workbook.sheets[0].parse_typed([ ColumnSpec(ColumnType.STRING, name="Region"), ColumnSpec(ColumnType.DATE, name="Order Date"), ColumnSpec(ColumnType.F64, name="Total Revenue", nullable=True), @@ -114,8 +147,8 @@ header, where every spec must resolve by index. A column that fails to convert is an error unless its spec sets `nullable=True`, which records the failure in `table.validity` and keeps reading. -Note that `parse_typed()` always reads the whole sheet from its first row, independent of how far -`rows()` has advanced — and it leaves that cursor alone. +`parse_typed()` always reads the whole sheet from its first row, independent of any `rows()` +generator in progress. #### Reading a sheet a batch at a time @@ -129,19 +162,17 @@ with open_workbook("sales.xlsb") as workbook: ColumnSpec(ColumnType.DATE, name="Order Date"), ColumnSpec(ColumnType.F64, name="Total Revenue", nullable=True), ] - for batch in workbook.iter_parse_typed(schema, batch_size=10_000): + for batch in workbook.sheets[0].iter_parse_typed(schema, batch_size=10_000): region, day, revenue = batch.columns ... ``` `batch_size` is rows per batch; `0` means one unbounded batch, identical to `parse_typed()`, and a -negative value is an error. A workbook serves one chunked read at a time — of either kind, typed or -Arrow (see [Arrow](#arrow) below) — and any other read on the workbook while one is live invalidates -it: its next call then raises `ExcelReaderError` rather than silently resuming from the moved cursor. -Finish the batches, or call `.close()` on the generator, before starting another read. +negative value is an error. Chunked reads are independent: several can be open on one workbook at +once, typed or Arrow (see [Arrow](#arrow) below), and none disturbs another. Being a generator, `iter_parse_typed()` opens nothing until the first iteration, so a bad -`batch_size` or a rejected second reader is only raised there, not at the call. +`batch_size` is only raised there, not at the call. #### Reading a large CSV on several threads @@ -152,7 +183,7 @@ either way. Every partition's columns are held until they are merged, so the pea than a sequential read of the same file. `to_polars()` with `parallelism` other than 1 parses the whole file instead of streaming it in batches. -`xl_parse_typed_ex` on a Ryzen 7 5700X (8 cores, 16 threads), 14 typed columns, file read from memory: +`xl_parse_typed` on a Ryzen 7 5700X (8 cores, 16 threads), 14 typed columns, file read from memory: | File | `parallelism=1` | `parallelism=0` | Speed-up | |---|---:|---:|---:| @@ -169,8 +200,9 @@ don't, `infer_schema()` samples the sheet and guesses one for you: ```python with open_workbook("sales.xlsb") as workbook: - schema = workbook.infer_schema() # header_row=1, sample_size=100 by default - table = workbook.parse_typed(schema) + sheet = workbook.sheets[0] + schema = sheet.infer_schema() # header_row=1, sample_size=100 by default + table = sheet.parse_typed(schema) ``` By default each column's type comes from the `CellType` Excel already stored for its sampled cells, @@ -185,8 +217,9 @@ still a guess over the sample, so a value further down can fail to convert: ```python with open_workbook("sales.csv") as workbook: - schema = workbook.infer_schema(parse_text=True) - table = workbook.parse_typed(schema, parallelism=0) + sheet = workbook.sheets[0] + schema = sheet.infer_schema(parse_text=True) + table = sheet.parse_typed(schema, parallelism=0) ``` ### Writing @@ -198,7 +231,8 @@ through the same `xl_write_typed` native export — one-shot, no writer handle b from excelreader import ColumnType, write_workbook with open_workbook("sales.xlsb") as workbook: - table = workbook.parse_typed(workbook.infer_schema()) + sheet = workbook.sheets[0] + table = sheet.parse_typed(sheet.infer_schema()) types = [ColumnType.STRING, ColumnType.DATE, ColumnType.F64] # one per table.columns, in order write_workbook("sales_copy.xlsx", table, types) @@ -292,7 +326,7 @@ over the Arrow C Data Interface: import pyarrow as pa with open_workbook("sales.xlsb") as workbook: - array = workbook.to_arrow(schema) + array = workbook.sheets[0].to_arrow(schema) batch = pa.RecordBatch.from_struct_array(array) ``` @@ -306,20 +340,20 @@ pyarrow owns the buffers from that point on, so the result stays valid after the ```python with open_workbook("sales.xlsb") as workbook: - reader = workbook.to_record_batch_reader(schema, batch_size=10_000) + reader = workbook.sheets[0].to_record_batch_reader(schema, batch_size=10_000) for batch in reader: ... ``` -Same `batch_size`/one-chunked-read-at-a-time rules as `iter_parse_typed()` above, except -`to_record_batch_reader()` raises immediately rather than on the first iteration. +Same `batch_size` rules as `iter_parse_typed()` above, except `to_record_batch_reader()` raises +immediately rather than on the first iteration. The reader keeps working after the workbook is closed. `iter_pandas()`/`iter_polars()` build on it, yielding one DataFrame per batch (requires pyarrow, plus pandas or polars respectively): ```python with open_workbook("sales.xlsb") as workbook: - for frame in workbook.iter_polars(schema, batch_size=10_000): + for frame in workbook.sheets[0].iter_polars(schema, batch_size=10_000): ... ``` @@ -356,7 +390,7 @@ from excelreader import OpenOptions, open_workbook # A semicolon-delimited CSV, which the default comma dialect would read as one column per row. with open_workbook("export.csv", format="csv", options=OpenOptions(csv_delimiter=ord(";"))) as workbook: - for row in workbook.rows(): + for row in workbook.sheets[0].rows(): ... ``` @@ -488,6 +522,6 @@ fixed-width records with no compression, so it trades 3.5x the bytes for less wo ## Notes -- A `Workbook` is **not** thread-safe. Use one per thread. +- A `Workbook` can be read from several threads at once; see [Sheets](#sheets). - Empty cells are skipped, so `cell.column` may skip indices. Do not assume `row[i].column == i`. - The ABI is documented in `src/ExcelReader.Native/include/excelreader.h`. From 567c28929c5faa8b5619f3cecc8473e414d14615 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:02:06 -0300 Subject: [PATCH 39/49] fix(rust): let decoded rows and typed tables cross threads DecodedRows and TableView own plain native allocations, so they are Send and Sync and can be returned from scoped threads. Workbook::sheet takes its error codes from the native side. The migration guide says read-alls now start from the first row. Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/migrating-to-v6.md | 3 ++- rust/excelreader/src/arrow.rs | 4 ++-- rust/excelreader/src/rows.rs | 9 +++++-- rust/excelreader/src/workbook.rs | 17 +++++++------ rust/excelreader/tests/sheets.rs | 41 ++++++++++++++++++++++++++++---- 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index c1560977..f5081b35 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -220,7 +220,8 @@ The crate follows the library: reads move from `Workbook` to `Sheet`. | `parse_arrow::(&mut workbook, h)?`, `parse_arrow_stream` | the same, taking a `Sheet` | `Workbook` no longer needs to be `mut`, and it is `Send + Sync`. A cursor reads its sheet once from -the top; call `rows()` again for another pass. A crate built for ABI 5 refuses a v6 library with an +the top; call `rows()` again for another pass. `Sheet::read_all_blob()` and `read_all_decoded()` +read the whole sheet from its first row; they no longer continue a cursor. A crate built for ABI 5 refuses a v6 library with an ABI-version error at the first `Workbook::open`. ### Python diff --git a/rust/excelreader/src/arrow.rs b/rust/excelreader/src/arrow.rs index 2f7c2908..6a01e0ce 100644 --- a/rust/excelreader/src/arrow.rs +++ b/rust/excelreader/src/arrow.rs @@ -51,8 +51,8 @@ pub fn parse_arrow(sheet: Sheet<'_>, header_row: i32) -> Result< /// Batched counterpart to [`parse_arrow`], one [`RecordBatch`] at a time. /// -/// Wraps arrow-rs's `'static` reader only to carry the `'a` borrow, which is what proves the -/// workbook is untouched while the stream lives. Unlike [`crate::workbook::TypedChunks`] it does +/// Wraps arrow-rs's `'static` reader only to carry the `'a` borrow, which keeps the +/// workbook alive for at least as long as the stream. Unlike [`crate::workbook::TypedChunks`] it does /// not fuse after an error, so break on the first `Err` rather than spinning on the latched one. pub struct ArrowChunks<'a> { inner: ArrowArrayStreamReader, diff --git a/rust/excelreader/src/rows.rs b/rust/excelreader/src/rows.rs index 68927826..58a50217 100644 --- a/rust/excelreader/src/rows.rs +++ b/rust/excelreader/src/rows.rs @@ -293,7 +293,7 @@ impl Drop for RowCursor<'_> { } } -/// Every remaining row of a sheet, decoded natively in one call. +/// Every row of a sheet, decoded natively in one call. /// /// Owns the native allocation and releases it on drop. Rows and cells borrow from it, so they /// cannot outlive it. @@ -336,6 +336,11 @@ impl Drop for DecodedRows { } } +// SAFETY: plain native allocations, read only through &self, released by xl_free_rows, which any +// thread may call. +unsafe impl Send for DecodedRows {} +unsafe impl Sync for DecodedRows {} + impl std::fmt::Debug for DecodedRows { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DecodedRows").field("len", &self.len()).finish() @@ -345,7 +350,7 @@ impl std::fmt::Debug for DecodedRows { /// Rows are usually well under this; it only sets how often an oversized sheet costs a retry. const INITIAL_ALL_ROWS_BUFFER: usize = 1024 * 1024; -/// Every remaining row of a sheet, read into one flat buffer by a single `xl_rows_read_all_blob` call. +/// Every row of a sheet, read into one flat buffer by a single `xl_rows_read_all_blob` call. /// /// Unlike [`DecodedRows`], the native side allocates nothing per row/cell here - `xl_rows_read_all_blob` /// writes the same wire format `RowCursor::next_row` decodes, one row after another with a length diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs index 50cde0fb..76df5f20 100644 --- a/rust/excelreader/src/workbook.rs +++ b/rust/excelreader/src/workbook.rs @@ -229,13 +229,8 @@ impl Workbook { /// The sheet at `index`. An index outside the workbook is an error here, not at the first read. pub fn sheet(&self, index: i32) -> Result, Error> { - let count = self.sheet_count()?; - if index < 0 || index >= count { - return Err(Error::from_status( - XL_ERROR, - format!("sheet index {index} is out of range; the workbook has {count} sheets"), - )); - } + let mut visibility: i32 = 0; + check(unsafe { crate::xl_sheet_visibility_at(self.handle, index, &mut visibility) })?; Ok(Sheet { workbook: self, index }) } @@ -322,10 +317,13 @@ impl<'w> Sheet<'w> { self.index } + /// The sheet's name. pub fn name(&self) -> Result { self.workbook.sheet_name_at(self.index) } + /// Whether the sheet is visible, hidden or very hidden. An error if the native side reports a + /// value this crate does not know. pub fn visibility(&self) -> Result { let mut raw: i32 = 0; check(unsafe { @@ -676,6 +674,11 @@ impl Drop for TableView { } } +// SAFETY: plain native allocations, read only through &self, released by xl_free_table, which any +// thread may call. T appears only as fn pointers, never as a stored value. +unsafe impl Send for TableView {} +unsafe impl Sync for TableView {} + /// Shape only. Rendering the rows would mean materializing every `T`, which is the one thing this /// type exists to avoid. impl std::fmt::Debug for TableView { diff --git a/rust/excelreader/tests/sheets.rs b/rust/excelreader/tests/sheets.rs index f981bcd3..ff40605c 100644 --- a/rust/excelreader/tests/sheets.rs +++ b/rust/excelreader/tests/sheets.rs @@ -1,6 +1,12 @@ -use excelreader::workbook::{Sheet, SheetVisibility, Workbook}; +use excelreader::workbook::{ExcelMapper, Sheet, SheetVisibility, Workbook}; use excelreader::writer_handle::WriterHandle; -use excelreader::{RowCursor, XL_FORMAT_AUTO, XL_FORMAT_XLSX}; +use excelreader::{RowCursor, XL_ERROR, XL_FORMAT_AUTO, XL_FORMAT_XLSX, XL_INVALID_ARGUMENT}; + +#[derive(Default, ExcelMapper)] +struct R { + #[excel(name = "a")] + a: String, +} fn two_sheets() -> Workbook { let mut writer = WriterHandle::open_memory(XL_FORMAT_XLSX, None).expect("open writer"); @@ -66,8 +72,10 @@ fn a_cursor_dropped_midway_leaves_the_workbook_usable() { fn sheet_rejects_an_index_out_of_range_at_the_call() { let workbook = two_sheets(); - assert!(workbook.sheet(2).is_err()); - assert!(workbook.sheet(-1).is_err()); + let past_end = workbook.sheet(2).expect_err("past the end"); + assert_eq!(past_end.code(), XL_ERROR); + assert!(!past_end.message().is_empty()); + assert_eq!(workbook.sheet(-1).expect_err("negative").code(), XL_INVALID_ARGUMENT); } #[test] @@ -139,6 +147,31 @@ fn workbook_and_sheet_cross_threads_and_cursors_move_between_them() { assert_send_sync::(); assert_send_sync::>(); assert_send::>(); + assert_send_sync::(); + assert_send_sync::>(); + assert_send::>(); + #[cfg(feature = "arrow")] + assert_send::>(); +} + +#[test] +fn decoded_rows_come_back_from_scoped_threads() { + let workbook = many_sheets(); + let sheets = workbook.sheets().expect("sheets"); + let counts: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = sheets + .iter() + .map(|sheet| { + let sheet = *sheet; + scope.spawn(move || sheet.read_all_decoded()) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("no panic").expect("read").len()) + .collect() + }); + assert_eq!(counts, vec![PARALLEL_ROWS; PARALLEL_SHEETS]); } #[test] From 0a803c51ab86c9a6071f053a266a247ab5578e1c Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:04:34 -0300 Subject: [PATCH 40/49] fix(cpp): read a cursor's remaining rows, warm the runtime once, and drop jthread RowCursor::read_all_decoded returns what the cursor has left; the sheet form reads the whole sheet, and the migration guide says so. The first ABI check runs one small read so later threads do not race the runtime's start-up. The sheets test uses std::thread for libc++. Co-Authored-By: Claude Sonnet 5.5 --- cpp/README.md | 16 +- cpp/tests/sheets.cpp | 50 +++++- docs/guide/migrating-to-v6.md | 7 +- .../include/excelreader.hpp | 157 +++++++++++------- 4 files changed, 155 insertions(+), 75 deletions(-) diff --git a/cpp/README.md b/cpp/README.md index 9c713a13..d21e5162 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -135,11 +135,15 @@ if (auto totals = workbook->sheet_by_name("Totals").value()) One open workbook can be read from several threads, which loads the shared-string table once: ```cpp -std::vector threads; +std::vector threads; for (xl::Sheet sheet : sheets) { threads.emplace_back([sheet] { auto table = xl::parse_sheet(sheet); /* ... */ }); } +for (std::thread &thread : threads) +{ + thread.join(); +} ``` A cursor, a typed reader and an Arrow stream are each used by one thread at a time, and keep @@ -165,8 +169,8 @@ while (auto row = cursor.next_row()) ``` Each row is invalidated by the next `next_row()`. To hold every row at once, use -`read_all_decoded()`, which decodes the whole remaining sheet in one native call and owns it until -destroyed: +`read_all_decoded()`, which decodes the whole sheet in one native call and owns it until +destroyed (`RowCursor::read_all_decoded()` does the same for the rows a cursor has left): ```cpp auto rows = workbook.sheet(0).read_all_decoded().value(); @@ -396,7 +400,7 @@ Two workloads: - **Typed:** `parse_sheet` against zsv plus the same conversions done by hand (`std::string` for text, ISO dates, `std::from_chars` for the integers and doubles), one owned `FullRow` per row. All three produce the same checksum. -- **Cells:** every cell's bytes counted. ExcelReader goes through `RowCursor` (`xl_next_row_view`), zsv +- **Cells:** every cell's bytes counted. ExcelReader goes through `RowCursor` (`xl_rows_next_view`), zsv through its row handler. zsv's checksum is 3 bytes higher because it keeps the BOM in the first header cell. @@ -411,8 +415,8 @@ Measured on the same machine as above, GCC 16.2.0 (MSYS2 UCRT64, `-O3 -mavx2` fo **zsv is faster on both.** Typed, it is ~1.2x (`compat`) and ~1.3x (`fast`) faster than ExcelReader. Counting cells, it is ~2.0x and ~3.4x faster. -The cells gap is the C ABI, not the CSV parser. `xl_next_row_view` copies every cell's value into a -native row the handle owns, one call per row, and that alone costs ~10.6 ms here (calling it without +The cells gap is the C ABI, not the CSV parser. `xl_rows_next_view` copies every cell's value into a +native row the cursor owns, one call per row, and that alone costs ~10.6 ms here (calling it without reading any cell, measured separately). The managed reader reads the same file in ~5 ms under BenchmarkDotNet (`RealDataReadBenchmark.Csv_ExcelReader`), but that is a different harness and runtime, so it is context rather than a like-for-like number. diff --git a/cpp/tests/sheets.cpp b/cpp/tests/sheets.cpp index a9e506f4..ea302817 100644 --- a/cpp/tests/sheets.cpp +++ b/cpp/tests/sheets.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -11,7 +12,7 @@ namespace { - int failures = 0; + std::atomic failures = 0; void check(bool condition, const char *what) { @@ -109,7 +110,14 @@ int main() check(!negative.has_value() && negative.error().code == XL_INVALID_ARGUMENT, "a negative sheet index is an invalid argument"); auto decoded = workbook->sheet(1).read_all_decoded(); - check(decoded.has_value() && decoded->size() == 3, "read_all_decoded reads the named sheet"); + check(decoded.has_value() && decoded->size() == 3, "read_all_decoded reads the whole named sheet"); + auto partial = workbook->sheet(1).rows(); + check(partial.has_value() && partial->next_row().has_value(), "open a cursor and read one row"); + if (partial.has_value()) + { + auto rest = partial->read_all_decoded(); + check(rest.has_value() && rest->size() == 2, "the cursor's read_all_decoded returns the rows it has left"); + } auto schema = workbook->sheet(1).infer_schema(0, 10); check(schema.has_value() && schema->size() == 1, "infer_schema samples the named sheet"); } @@ -150,6 +158,36 @@ int main() } } + { + auto workbook = xl::Workbook::open_memory(two); + check(workbook.has_value(), "open for the move-assigned cursor"); + if (workbook.has_value()) + { + auto a = workbook->sheet(0).rows(); + auto b = workbook->sheet(1).rows(); + check(a.has_value() && b.has_value(), "open two cursors to move-assign"); + if (a.has_value() && b.has_value()) + { + check(a->next_row().has_value(), "the overwritten cursor advances"); + *a = std::move(*b); + check(b->handle() == nullptr, "a move-assigned-from cursor holds nothing"); + const auto values = drain(*a); + check(values.size() == 3 && values.front() == "s1-r0", "the move-assigned cursor reads the other sheet from the top"); + } + } + } + + { + const std::vector empty_book = build(1, 0); + auto workbook = xl::Workbook::open_memory(empty_book); + check(workbook.has_value(), "open the empty workbook"); + if (workbook.has_value()) + { + auto decoded = workbook->sheet(0).read_all_decoded(); + check(decoded.has_value() && decoded->empty(), "an empty sheet decodes to no rows"); + } + } + { std::optional survivor; std::optional stale; @@ -195,12 +233,16 @@ int main() std::vector> parallel(static_cast(sheet_count) * 2); { - std::vector threads; + std::vector threads; for (size_t i = 0; i < parallel.size(); ++i) { threads.emplace_back([¶llel, &workbook, i] { parallel[i] = read_sheet(workbook->sheet(static_cast(i % sheet_count))); }); } + for (std::thread &thread : threads) + { + thread.join(); + } } bool same = true; for (size_t i = 0; i < parallel.size(); ++i) @@ -213,7 +255,7 @@ int main() if (failures != 0) { - std::fprintf(stderr, "%d check(s) failed\n", failures); + std::fprintf(stderr, "%d check(s) failed\n", failures.load()); return 1; } std::puts("OK: sheets"); diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index f5081b35..66a31296 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -251,6 +251,9 @@ The wrapper follows the library: reads move from `xl::Workbook` to `xl::Sheet`. | `workbook.read_all_decoded()`, `workbook.infer_schema(h, n)` | the same methods on a `Sheet` | | `xl::parse_sheet(workbook, h)`, `xl::typed_reader(workbook, h, n)` | the same, taking a `Sheet` | | `xl::parse_arrow(workbook, h)`, `xl::arrow_stream(workbook, h, n)` | the same, taking a `Sheet` | +| `rows()` then `next_row()` then `read_all_decoded()` | `auto cursor = sheet.rows(); cursor->next_row(); cursor->read_all_decoded();` | -`xl::RowCursor` now owns a native cursor and is move-only. A header built for ABI 5 refuses a v6 -library with an ABI-version error at the first `Workbook::open`. +`xl::RowCursor` now owns a native cursor and is move-only. `Sheet::read_all_decoded()` reads the whole +sheet from its first row; to read what a cursor has left, call `RowCursor::read_all_decoded()`. Code +built against the v5 header does not link or load against a v6 library, because the v5 exports it +names are gone. diff --git a/src/ExcelReader.Native/include/excelreader.hpp b/src/ExcelReader.Native/include/excelreader.hpp index c9077c1c..f3e39eb9 100644 --- a/src/ExcelReader.Native/include/excelreader.hpp +++ b/src/ExcelReader.Native/include/excelreader.hpp @@ -75,6 +75,29 @@ namespace xl namespace detail { + // The first calls into the runtime from several threads at once can crash it; finish its + // start-up on this thread first (see commit b5f8c5f). Failures are ignored. + inline void warm_up() noexcept + { + static constexpr char csv[] = "a,b\n1,2\n3,4\n"; + xl_workbook *workbook = nullptr; + if (xl_open_memory(reinterpret_cast(csv), static_cast(sizeof(csv) - 1), + XL_FORMAT_CSV, nullptr, &workbook) != XL_OK || workbook == nullptr) + { + return; + } + xl_rows *rows = nullptr; + if (xl_rows_open(workbook, 0, &rows) == XL_OK && rows != nullptr) + { + xl_row row{}; + while (xl_rows_next_view(rows, &row) == XL_OK) + { + } + xl_rows_close(rows); + } + xl_close(workbook); + } + inline const std::expected &check_abi_version() { static const std::expected result = []() -> std::expected @@ -82,6 +105,7 @@ namespace xl const int32_t loaded = xl_abi_version(); if (loaded == XL_ABI_VERSION) { + warm_up(); return {}; } return std::unexpected(Error{ @@ -532,61 +556,6 @@ namespace xl size_t count_{}; }; - /// Reads one sheet row by row. Owns a native cursor and closes it on destruction. Use one - /// cursor from one thread at a time; any number may be open on one workbook. - class RowCursor - { - public: - explicit RowCursor(xl_rows *rows) noexcept : rows_(rows) {} - - RowCursor(const RowCursor &) = delete; - RowCursor &operator=(const RowCursor &) = delete; - - RowCursor(RowCursor &&other) noexcept : rows_(std::exchange(other.rows_, nullptr)) {} - RowCursor &operator=(RowCursor &&other) noexcept - { - if (this != &other) - { - close(); - rows_ = std::exchange(other.rows_, nullptr); - } - return *this; - } - - ~RowCursor() { close(); } - - /// The returned view points into memory the cursor owns and is valid until the next - /// next_row() on this cursor or until the cursor is destroyed. - std::expected next_row() - { - xl_row row{}; - const int32_t status = xl_rows_next_view(rows_, &row); - if (status != XL_OK) - { - return std::unexpected(detail::make_error(status)); - } - if (row.cell_count < 0) - { - return std::unexpected(detail::make_error(XL_ERROR)); - } - return RowView(row.cells, static_cast(row.cell_count)); - } - - xl_rows *handle() const noexcept { return rows_; } - - private: - void close() noexcept - { - if (rows_ != nullptr) - { - xl_rows_close(rows_); - rows_ = nullptr; - } - } - - xl_rows *rows_ = nullptr; - }; - class DecodedRows { public: @@ -663,6 +632,73 @@ namespace xl }; + /// Reads one sheet row by row. Owns a native cursor and closes it on destruction. Use one + /// cursor from one thread at a time; any number may be open on one workbook. + class RowCursor + { + public: + explicit RowCursor(xl_rows *rows) noexcept : rows_(rows) {} + + RowCursor(const RowCursor &) = delete; + RowCursor &operator=(const RowCursor &) = delete; + + RowCursor(RowCursor &&other) noexcept : rows_(std::exchange(other.rows_, nullptr)) {} + RowCursor &operator=(RowCursor &&other) noexcept + { + if (this != &other) + { + close(); + rows_ = std::exchange(other.rows_, nullptr); + } + return *this; + } + + ~RowCursor() { close(); } + + /// The returned view points into memory the cursor owns and is valid until the next + /// next_row() on this cursor or until the cursor is destroyed. + std::expected next_row() + { + xl_row row{}; + const int32_t status = xl_rows_next_view(rows_, &row); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + if (row.cell_count < 0) + { + return std::unexpected(detail::make_error(XL_ERROR)); + } + return RowView(row.cells, static_cast(row.cell_count)); + } + + /// Every row this cursor has not yet returned, decoded in one native call. + std::expected read_all_decoded() + { + xl_rows_decoded raw{}; + const int32_t status = xl_rows_read_all_decoded(rows_, &raw); + if (status != XL_OK) + { + return std::unexpected(detail::make_error(status)); + } + return DecodedRows(raw); + } + + xl_rows *handle() const noexcept { return rows_; } + + private: + void close() noexcept + { + if (rows_ != nullptr) + { + xl_rows_close(rows_); + rows_ = nullptr; + } + } + + xl_rows *rows_ = nullptr; + }; + enum class SheetVisibility : int32_t { Visible = XL_SHEET_VISIBLE, @@ -672,7 +708,7 @@ namespace xl /// One sheet of a Workbook: the native workbook handle and an index. Holds no resource of its /// own, so it is cheap to copy and may be handed to another thread; each read opens what it - /// needs. It must not be used after its workbook is destroyed. + /// needs. After its workbook is destroyed, every read on it returns XL_INVALID_HANDLE. class Sheet { public: @@ -709,6 +745,7 @@ namespace xl return RowCursor(rows); } + /// The whole sheet from its first row, decoded in one native call. std::expected read_all_decoded() const { auto cursor = rows(); @@ -716,13 +753,7 @@ namespace xl { return std::unexpected(cursor.error()); } - xl_rows_decoded raw{}; - const int32_t status = xl_rows_read_all_decoded(cursor->handle(), &raw); - if (status != XL_OK) - { - return std::unexpected(detail::make_error(status)); - } - return DecodedRows(raw); + return cursor->read_all_decoded(); } /// flags: 0, or XL_INFER_PARSE_TEXT to also type cells that hold text. From 47c56055044614c950d1e8bf9023afabbe89af8e Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:06:01 -0300 Subject: [PATCH 41/49] fix(python): type the sheet lookup, give sheets value equality, and warm the runtime once Sheets.__getitem__ is overloaded so sheets[i] types as a Sheet, and two Sheet objects for the same workbook and index compare equal. The first library load runs one small read on one thread so later threads do not race the runtime's start-up. ArrowStream no longer holds the workbook. The migration guide says reads now start from the first row. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 ++-- docs/guide/migrating-to-v6.md | 2 + python/README.md | 7 +-- python/src/excelreader/__init__.py | 3 +- python/src/excelreader/_native.py | 66 +++++++++++++++++++------- python/src/excelreader/arrow_stream.py | 13 ++--- python/src/excelreader/reader.py | 24 +++++++--- python/src/excelreader/types.py | 16 ++++--- python/src/excelreader/writer.py | 2 +- python/tests/test_sheets.py | 27 +++++++++++ 10 files changed, 121 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ec25938a..57376477 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ XLSX, XLSB, XLS and CSV without a .NET runtime installed. from excelreader import open_workbook with open_workbook("book.xlsx") as workbook: - for row in workbook.rows(): + for row in workbook.sheets[0].rows(): print([cell.value for cell in row]) ``` @@ -130,9 +130,9 @@ write_sheet("out.xlsx", XL_FORMAT_XLSX, &rows, None)?; auto written = xl::write_sheet("out.xlsx", rows); // format inferred from the extension ``` -Row-by-row decoded reads are available from all three bindings — Python as `Workbook.rows()`, C++ -as `xl::Workbook::rows()`, Rust as `Workbook::rows()`. Python additionally exposes -`read_all_columnar()` over `xl_read_all_blob`, which the other two do not wrap. +Row-by-row decoded reads are available from all three bindings — Python as `Sheet.rows()`, C++ +as `xl::Sheet::rows()`, Rust as `Sheet::rows()`. Python additionally exposes +`read_all_columnar()` over `xl_rows_read_all_blob`, which the other two do not wrap. The Arrow export is available from Python (`to_arrow`/`to_record_batch`), C++ (`xl::parse_arrow`, in the separate `` diff --git a/docs/guide/migrating-to-v6.md b/docs/guide/migrating-to-v6.md index 66a31296..ca58d48e 100644 --- a/docs/guide/migrating-to-v6.md +++ b/docs/guide/migrating-to-v6.md @@ -239,6 +239,8 @@ The package follows the library: reads move from `Workbook` to `Sheet`. `workbook.sheets["Name"]` finds a sheet by name and raises `KeyError` when there is none. A package built for ABI 5 refuses a v6 library when it loads it. +Each `rows()` call starts at the sheet's first row, and `read_all()` / `read_all_columnar()` read the whole sheet; none of them continues an earlier read. A `rows()` generator that has started keeps working after `workbook.close()`, where v5 raised. + ### C++ The wrapper follows the library: reads move from `xl::Workbook` to `xl::Sheet`. diff --git a/python/README.md b/python/README.md index 78b5d73e..74160f1e 100644 --- a/python/README.md +++ b/python/README.md @@ -52,12 +52,13 @@ The native calls release the GIL: from concurrent.futures import ThreadPoolExecutor with excelreader.open_workbook("report.xlsx") as workbook, ThreadPoolExecutor() as pool: - tables = list(pool.map(lambda sheet: sheet.to_arrow(schema), workbook.sheets)) + tables = list(pool.map(lambda sheet: sheet.to_arrow(sheet.infer_schema()), workbook.sheets)) ``` `rows()` and `iter_parse_typed()` are generators: the native cursor opens at the first `next()`. A -generator or a `RecordBatchReader` that has already started keeps working after `workbook.close()`; -a generator that has not started raises at its first `next()`. +generator that has already started keeps working after `workbook.close()`; one that has not started +raises at its first `next()`. A `RecordBatchReader` opens its stream at the call, so it works after +`close()` even if never started. ### Formats diff --git a/python/src/excelreader/__init__.py b/python/src/excelreader/__init__.py index 06c1a9df..b5cf6ba4 100644 --- a/python/src/excelreader/__init__.py +++ b/python/src/excelreader/__init__.py @@ -1,7 +1,7 @@ """Python bindings for ExcelReader.""" from excelreader.arrow_stream import ArrowStream -from excelreader.reader import Sheet, Workbook, decode_cell, open_bytes, open_workbook +from excelreader.reader import Sheet, Sheets, Workbook, decode_cell, open_bytes, open_workbook from excelreader.types import ( Cell, CellType, @@ -45,6 +45,7 @@ "Sheet", "SheetVisibility", "SheetWriter", + "Sheets", "StringColumn", "TypedTable", "Workbook", diff --git a/python/src/excelreader/_native.py b/python/src/excelreader/_native.py index 2fcd78f5..a619aacb 100644 --- a/python/src/excelreader/_native.py +++ b/python/src/excelreader/_native.py @@ -8,6 +8,7 @@ import ctypes import os import platform +import threading from collections.abc import Sequence from functools import lru_cache from pathlib import Path @@ -376,24 +377,57 @@ def _candidate_paths() -> list[Path]: return [Path(__file__).resolve().parent / "_lib" / library_filename()] +_load_lock = threading.Lock() + + @lru_cache(maxsize=1) def load_library() -> ctypes.CDLL: - for path in _candidate_paths(): - if not path.exists(): - continue - lib = _bind(ctypes.CDLL(str(path))) - version = lib.xl_abi_version() - if version != XL_ABI_VERSION: - raise RuntimeError( - f"{path} is ABI version {version}, but this package expects {XL_ABI_VERSION}. " - f"Rebuild the native library with python python/scripts/build_native.py." - ) - return lib - raise RuntimeError( - f"{library_filename()} not found. Build it with:\n" - f" python python/scripts/build_native.py\n" - f"or point EXCELREADER_NATIVE_LIB at an existing binary." - ) + with _load_lock: + for path in _candidate_paths(): + if not path.exists(): + continue + lib = _bind(ctypes.CDLL(str(path))) + version = lib.xl_abi_version() + if version != XL_ABI_VERSION: + raise RuntimeError( + f"{path} is ABI version {version}, but this package expects {XL_ABI_VERSION}. " + f"Rebuild the native library with python python/scripts/build_native.py." + ) + _warm_up(lib) + return lib + raise RuntimeError( + f"{library_filename()} not found. Build it with:\n" + f" python python/scripts/build_native.py\n" + f"or point EXCELREADER_NATIVE_LIB at an existing binary." + ) + + +def _warm_up(lib: ctypes.CDLL) -> None: + # Threads whose first native call lands together can crash the NativeAOT runtime's start-up + # (see commit b5f8c5f); one small read here finishes it on a single thread. Failures are ignored. + data = b"a,b\n1,2\n3,4\n" + workbook = ctypes.c_void_p() + if lib.xl_open_memory(data, len(data), XL_FORMAT_CSV, None, ctypes.byref(workbook)) != XL_OK: + return + try: + cursor = ctypes.c_void_p() + if lib.xl_rows_open(workbook, 0, ctypes.byref(cursor)) != XL_OK: + return + try: + written = ctypes.c_int32() + capacity = 16 + buffer = ctypes.create_string_buffer(capacity) + while True: + status = lib.xl_rows_next(cursor, buffer, capacity, ctypes.byref(written)) + if status == XL_BUFFER_TOO_SMALL: + capacity = written.value + buffer = ctypes.create_string_buffer(capacity) + elif status != XL_OK: + return + finally: + lib.xl_rows_close(cursor) + finally: + lib.xl_close(workbook) def _bind(lib: ctypes.CDLL) -> ctypes.CDLL: diff --git a/python/src/excelreader/arrow_stream.py b/python/src/excelreader/arrow_stream.py index 3ad68433..bd3c0399 100644 --- a/python/src/excelreader/arrow_stream.py +++ b/python/src/excelreader/arrow_stream.py @@ -3,14 +3,10 @@ from __future__ import annotations import ctypes -from typing import TYPE_CHECKING from excelreader import _native from excelreader.types import ExcelReaderError -if TYPE_CHECKING: - from excelreader.reader import Workbook - _CAPSULE_NAME = b"arrow_array_stream" _capsule_new = ctypes.pythonapi.PyCapsule_New @@ -21,7 +17,7 @@ _capsule_pointer.argtypes = [ctypes.c_void_p, ctypes.c_char_p] _capsule_pointer.restype = ctypes.c_void_p -_live: dict[int, tuple[_native.ArrowArrayStream, Workbook]] = {} +_live: dict[int, _native.ArrowArrayStream] = {} def _release(stream: _native.ArrowArrayStream) -> None: @@ -31,7 +27,7 @@ def _release(stream: _native.ArrowArrayStream) -> None: def _destroy_capsule(capsule: int) -> None: address = _capsule_pointer(capsule, _CAPSULE_NAME) - stream, _workbook = _live.pop(address) + stream = _live.pop(address) _release(stream) @@ -46,16 +42,15 @@ class ArrowStream: takes it, the stream is spent; dropping it unconsumed releases the native read. """ - def __init__(self, stream: _native.ArrowArrayStream, workbook: Workbook) -> None: + def __init__(self, stream: _native.ArrowArrayStream) -> None: self._stream: _native.ArrowArrayStream | None = stream - self._workbook = workbook def __arrow_c_stream__(self, requested_schema: object = None) -> object: if self._stream is None: raise ExcelReaderError("this Arrow stream was already consumed") stream, self._stream = self._stream, None address = ctypes.addressof(stream) - _live[address] = (stream, self._workbook) + _live[address] = stream return _capsule_new(address, _CAPSULE_NAME, _destroy_capsule_callback) def __del__(self) -> None: diff --git a/python/src/excelreader/reader.py b/python/src/excelreader/reader.py index 0d81c97d..1f873115 100644 --- a/python/src/excelreader/reader.py +++ b/python/src/excelreader/reader.py @@ -8,7 +8,7 @@ from array import array from collections.abc import Iterator, Sequence from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, SupportsIndex, overload if TYPE_CHECKING: from typing_extensions import Self @@ -130,7 +130,7 @@ def is_date1904(self) -> bool: return flag.value != 0 def close(self) -> None: - """Closes the workbook. Row generators and record-batch readers already opened keep working.""" + """Closes the workbook. A generator that has already started, and any record-batch reader already opened, keep working.""" if self._handle is None: return handle, self._handle = self._handle, None @@ -158,7 +158,11 @@ def __init__(self, workbook: Workbook) -> None: def __len__(self) -> int: return self._workbook.sheet_count - def __getitem__(self, key: int | str | slice) -> Sheet | list[Sheet]: # type: ignore[override] + @overload + def __getitem__(self, key: SupportsIndex | str) -> Sheet: ... + @overload + def __getitem__(self, key: slice) -> list[Sheet]: ... + def __getitem__(self, key: SupportsIndex | str | slice) -> Sheet | list[Sheet]: # type: ignore[override] workbook = self._workbook if isinstance(key, str): encoded = key.encode("utf-8") @@ -193,6 +197,14 @@ def __init__(self, workbook: Workbook, index: int) -> None: def __repr__(self) -> str: return f"Sheet(index={self._index})" + def __eq__(self, other: object) -> bool: + if not isinstance(other, Sheet): + return NotImplemented + return self._workbook is other._workbook and self._index == other._index + + def __hash__(self) -> int: + return hash((id(self._workbook), self._index)) + @property def index(self) -> int: """Zero-based position in the workbook.""" @@ -298,8 +310,8 @@ def iter_parse_typed( """`parse_typed()` a batch at a time, yielding one `TypedTable` per batch. `batch_size` is rows per batch; 0 means one unbounded batch, exactly what `parse_typed()` - does. The generator owns its position in the sheet and keeps working after the workbook is - closed. + does. A generator that has already started owns its position in the sheet and keeps working after + the workbook is closed. Being a generator, it opens nothing until the first iteration, so a bad `batch_size` is not reported until then; `to_record_batch_reader()` raises immediately instead. @@ -404,7 +416,7 @@ def to_arrow_stream( handle, self._index, specs, len(specs), header_row, batch_size, ctypes.byref(stream) ) ) - return ArrowStream(stream, self._workbook) + return ArrowStream(stream) def iter_pandas( self, schema: Sequence[ColumnSpec], header_row: int = 1, batch_size: int = 10000 diff --git a/python/src/excelreader/types.py b/python/src/excelreader/types.py index 288c09ab..8693c8c5 100644 --- a/python/src/excelreader/types.py +++ b/python/src/excelreader/types.py @@ -6,6 +6,8 @@ from enum import IntEnum from typing import Any, NamedTuple +from excelreader import _native + class CellType(IntEnum): EMPTY = 0 @@ -24,9 +26,9 @@ class SheetVisibility(IntEnum): renumber them. """ - VISIBLE = 0 - HIDDEN = 1 - VERY_HIDDEN = 2 + VISIBLE = _native.XL_SHEET_VISIBLE + HIDDEN = _native.XL_SHEET_HIDDEN + VERY_HIDDEN = _native.XL_SHEET_VERY_HIDDEN class Cell(NamedTuple): @@ -48,9 +50,9 @@ def as_date(self, date1904: bool = False) -> datetime.date | None: class ColumnarSheet(NamedTuple): - """Every cell of a sheet as parallel flat arrays, produced by `Workbook.read_all_columnar()`. + """Every cell of a sheet as parallel flat arrays, produced by `Sheet.read_all_columnar()`. - The fast path for large sheets: unlike `Workbook.read_all()`, decoding never constructs one + The fast path for large sheets: unlike `Sheet.read_all()`, decoding never constructs one `Cell`/`str` object per cell — only `int` values fill the index arrays below, and `values` holds every cell's raw UTF-8 bytes concatenated. Use `decode_cell()` to materialize a single `Cell` on demand, or slice `values` directly for bulk work. @@ -73,7 +75,7 @@ class ColumnarSheet(NamedTuple): class ColumnType(IntEnum): - """A column's requested type in a `Workbook.parse_typed()`/`to_arrow()` schema. + """A column's requested type in a `Sheet.parse_typed()`/`to_arrow()` schema. Mirrors XL_T_* in src/ExcelReader.Native/include/excelreader.h — the values are ABI, do not renumber them. The buffer layout each one produces is documented on `TypedTable.columns`. @@ -197,7 +199,7 @@ def __repr__(self) -> str: class TypedTable(NamedTuple): - """The result of `Workbook.parse_typed()`: one flat buffer per column, already type-converted. + """The result of `Sheet.parse_typed()`: one flat buffer per column, already type-converted. - `names[i]` is column `i`'s name as requested, or its index as a string when the spec resolved by index. diff --git a/python/src/excelreader/writer.py b/python/src/excelreader/writer.py index 669ec091..a9ea5db4 100644 --- a/python/src/excelreader/writer.py +++ b/python/src/excelreader/writer.py @@ -131,7 +131,7 @@ def write_workbook( ) -> None: """Writes `table` to `path` as a single sheet. - `table` is what `Workbook.parse_typed()` returns, so reading a sheet and writing it back out is a + `table` is what `Sheet.parse_typed()` returns, so reading a sheet and writing it back out is a round-trip through the same buffers. `types` gives each column's `ColumnType` and is required positionally: a `TypedTable` carries raw buffers (an `array`/`StringColumn`/NumPy array), and nothing about a raw buffer's element size alone distinguishes, say, I64 from TIME — both are diff --git a/python/tests/test_sheets.py b/python/tests/test_sheets.py index ea9b0044..159c8966 100644 --- a/python/tests/test_sheets.py +++ b/python/tests/test_sheets.py @@ -166,3 +166,30 @@ def test_a_record_batch_reader_outlives_its_workbook(): assert isinstance(table, pyarrow.Table) assert table.num_rows == _PARALLEL_ROWS assert table.column(0)[0].as_py() == 2_000_000 + + +def test_sheets_compare_by_workbook_and_index(): + data = _two_sheets() + with open_bytes(data) as workbook, open_bytes(data) as other: + sheets = workbook.sheets + assert sheets.index(sheets[1]) == 1 + assert sheets[0] in sheets + assert sheets[0] == sheets[0] + assert sheets[0] != sheets[1] + assert sheets[0] != other.sheets[0] + assert len({sheets[0], sheets[0], sheets[1]}) == 2 + + +def test_a_sheet_with_no_rows_reads_empty(): + with open_writer_to_memory("xlsx") as writer: + writer.start_sheet("Empty") + writer.end_sheet() + data = writer.bytes() + + with open_bytes(data) as workbook: + sheet = workbook.sheets[0] + assert sheet.read_all() == [] + columnar = sheet.read_all_columnar() + assert list(columnar.row_offsets) == [0] + assert columnar.values == b"" + assert list(sheet.rows()) == [] From 78228b5c660528766567ef2968349cce7a68a24a Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:12:27 -0300 Subject: [PATCH 42/49] fix(python): close a workbook once when several threads close it at once Workbook.close() takes the handle under a lock, so two threads closing the same workbook call xl_close once and neither raises. Co-Authored-By: Claude Sonnet 5.5 --- python/src/excelreader/reader.py | 7 ++++-- python/tests/test_sheets.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/python/src/excelreader/reader.py b/python/src/excelreader/reader.py index 1f873115..ca87f8ef 100644 --- a/python/src/excelreader/reader.py +++ b/python/src/excelreader/reader.py @@ -5,6 +5,7 @@ import ctypes import operator import struct +import threading from array import array from collections.abc import Iterator, Sequence from pathlib import Path @@ -95,6 +96,7 @@ class Workbook: """ def __init__(self, handle: ctypes.c_void_p) -> None: + self._close_lock = threading.Lock() self._lib = _native.load_library() self._handle: ctypes.c_void_p | None = handle @@ -131,9 +133,10 @@ def is_date1904(self) -> bool: def close(self) -> None: """Closes the workbook. A generator that has already started, and any record-batch reader already opened, keep working.""" - if self._handle is None: + with self._close_lock: + handle, self._handle = self._handle, None + if handle is None: return - handle, self._handle = self._handle, None _check(self._lib.xl_close(handle)) def __enter__(self) -> Self: diff --git a/python/tests/test_sheets.py b/python/tests/test_sheets.py index 159c8966..9a77b6ed 100644 --- a/python/tests/test_sheets.py +++ b/python/tests/test_sheets.py @@ -1,4 +1,5 @@ import gc +import threading from concurrent.futures import ThreadPoolExecutor import pytest @@ -193,3 +194,41 @@ def test_a_sheet_with_no_rows_reads_empty(): assert list(columnar.row_offsets) == [0] assert columnar.values == b"" assert list(sheet.rows()) == [] + + +def test_closing_from_many_threads_at_once_raises_nowhere(): + workbook = open_bytes(_two_sheets()) + barrier = threading.Barrier(32) + errors: list[BaseException] = [] + + def close() -> None: + barrier.wait() + try: + workbook.close() + except BaseException as error: # noqa: BLE001 + errors.append(error) + + threads = [threading.Thread(target=close) for _ in range(32)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert errors == [] + + +def test_closing_twice_calls_native_close_once(monkeypatch): + workbook = open_bytes(_two_sheets()) + real_close = workbook._lib.xl_close + calls = [] + + class StubLib: + def xl_close(self, handle): + calls.append(handle) + return real_close(handle) + + monkeypatch.setattr(workbook, "_lib", StubLib()) + workbook.close() + workbook.close() + + assert len(calls) == 1 From 504be892fca5c1d006e51d0b2ca13d68f92a5d5f Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:13:07 -0300 Subject: [PATCH 43/49] feat(rust): infer types from text and parse a CSV in parallel Sheet::infer_schema_parse_text passes XL_INFER_PARSE_TEXT, and parse_sheet_parallel / parse_arrow_parallel pass a degree of parallelism, matching what the Python and C++ bindings expose. Co-Authored-By: Claude Sonnet 5.5 --- rust/excelreader/README.md | 5 +++ rust/excelreader/src/arrow.rs | 12 ++++- rust/excelreader/src/lib.rs | 3 ++ rust/excelreader/src/workbook.rs | 35 ++++++++++++++- rust/excelreader/tests/parse_arrow.rs | 31 ++++++++++++- rust/excelreader/tests/parse_typed.rs | 63 ++++++++++++++++++++++++++- 6 files changed, 143 insertions(+), 6 deletions(-) diff --git a/rust/excelreader/README.md b/rust/excelreader/README.md index c96a928a..7727bbef 100644 --- a/rust/excelreader/README.md +++ b/rust/excelreader/README.md @@ -66,6 +66,11 @@ for column in workbook.sheet(1)?.infer_schema(1, 100)? { } ``` +`infer_schema_parse_text(header_row, sample_size)` also types text cells (every CSV field, or +numbers stored as text): integers, decimals, `true`/`false` and ISO dates. `parse_sheet_parallel` +and `arrow::parse_arrow_parallel` take a `degree_of_parallelism` (`0` = every processor, `1` = +sequential); only CSV is split, and the result equals `parse_sheet`'s. + A workbook hands out sheets. A `Sheet` is the workbook and an index: it holds no native resource, it is `Copy`, and every read on it opens its own cursor. diff --git a/rust/excelreader/src/arrow.rs b/rust/excelreader/src/arrow.rs index 6a01e0ce..72130276 100644 --- a/rust/excelreader/src/arrow.rs +++ b/rust/excelreader/src/arrow.rs @@ -21,6 +21,16 @@ use crate::{Error, XL_ERROR}; /// /// `header_row` has the same meaning as in `parse_sheet` (0 = no header). pub fn parse_arrow(sheet: Sheet<'_>, header_row: i32) -> Result { + parse_arrow_parallel::(sheet, header_row, 1) +} + +/// [`parse_arrow`] with a degree of parallelism: `0` uses every processor, `1` is sequential, `n` +/// up to `n` threads, negative is an error. Only CSV is split; the batch equals `parse_arrow`'s. +pub fn parse_arrow_parallel( + sheet: Sheet<'_>, + header_row: i32, + degree_of_parallelism: i32, +) -> Result { let arena = build_specs::(); let mut array = FFI_ArrowArray::empty(); @@ -33,7 +43,7 @@ pub fn parse_arrow(sheet: Sheet<'_>, header_row: i32) -> Result< arena.specs.as_ptr(), arena.specs.len() as i32, header_row, - 1, + degree_of_parallelism, &mut array as *mut FFI_ArrowArray as *mut c_void, &mut schema as *mut FFI_ArrowSchema as *mut c_void, ) diff --git a/rust/excelreader/src/lib.rs b/rust/excelreader/src/lib.rs index 2639471f..9a4970d0 100644 --- a/rust/excelreader/src/lib.rs +++ b/rust/excelreader/src/lib.rs @@ -56,6 +56,9 @@ pub const XL_T_TIME: i32 = 5; /// Microseconds since 1970-01-01T00:00:00Z, stored as `i64`. pub const XL_T_TIMESTAMP: i32 = 6; +/// `xl_infer_schema` flag: also type cells that hold text (integers, decimals, booleans, ISO dates). +pub const XL_INFER_PARSE_TEXT: i32 = 1; + pub const XL_SHEET_VISIBLE: i32 = 0; pub const XL_SHEET_HIDDEN: i32 = 1; pub const XL_SHEET_VERY_HIDDEN: i32 = 2; diff --git a/rust/excelreader/src/workbook.rs b/rust/excelreader/src/workbook.rs index 76df5f20..863fd498 100644 --- a/rust/excelreader/src/workbook.rs +++ b/rust/excelreader/src/workbook.rs @@ -399,6 +399,26 @@ impl<'w> Sheet<'w> { &self, header_row: i32, sample_size: i32, + ) -> Result, Error> { + self.infer_schema_flags(header_row, sample_size, 0) + } + + /// [`infer_schema`](Self::infer_schema), also typing cells that hold text — every CSV field, + /// or numbers stored as text: integers, decimals, `true`/`false` and ISO dates or date-times, + /// when the text has exactly that shape. Leading-zero codes such as `00123` stay text. + pub fn infer_schema_parse_text( + &self, + header_row: i32, + sample_size: i32, + ) -> Result, Error> { + self.infer_schema_flags(header_row, sample_size, crate::XL_INFER_PARSE_TEXT) + } + + fn infer_schema_flags( + &self, + header_row: i32, + sample_size: i32, + flags: i32, ) -> Result, Error> { let mut schema = XlInferredSchema { columns: std::ptr::null_mut(), @@ -410,7 +430,7 @@ impl<'w> Sheet<'w> { self.index, header_row, sample_size, - 0, + flags, &mut schema, ) })?; @@ -835,6 +855,17 @@ pub(crate) fn build_specs() -> SpecArena { pub fn parse_sheet( sheet: Sheet<'_>, header_row: i32, +) -> Result, Error> { + parse_sheet_parallel(sheet, header_row, 1) +} + +/// [`parse_sheet`] with a degree of parallelism: `0` uses every processor, `1` is sequential, `n` +/// up to `n` threads, negative is an error. Only CSV is split; other formats read sequentially. +/// The table equals `parse_sheet`'s. +pub fn parse_sheet_parallel( + sheet: Sheet<'_>, + header_row: i32, + degree_of_parallelism: i32, ) -> Result, Error> { let arena = build_specs::(); let bindings = arena.bindings; @@ -850,7 +881,7 @@ pub fn parse_sheet( arena.specs.as_ptr(), arena.specs.len() as i32, header_row, - 1, + degree_of_parallelism, &mut table, ); if status != XL_OK { diff --git a/rust/excelreader/tests/parse_arrow.rs b/rust/excelreader/tests/parse_arrow.rs index 50f69113..80bcd93e 100644 --- a/rust/excelreader/tests/parse_arrow.rs +++ b/rust/excelreader/tests/parse_arrow.rs @@ -2,8 +2,9 @@ use arrow::array::{Array, Int64Array, StringArray}; use arrow::record_batch::RecordBatchReader; -use excelreader::arrow::{parse_arrow, parse_arrow_stream}; +use excelreader::arrow::{parse_arrow, parse_arrow_parallel, parse_arrow_stream}; use excelreader::workbook::{ExcelMapper, Workbook}; +use excelreader::XL_FORMAT_CSV; #[derive(Default, ExcelMapper)] struct Record { @@ -90,3 +91,31 @@ fn two_arrow_streams_on_one_workbook_each_yield_every_row() { assert_eq!(total(one), 100); assert_eq!(total(two), 100); } + +#[derive(Default, ExcelMapper)] +struct CsvRecord { + #[excel(name = "id")] + id: i64, + #[excel(name = "name")] + name: String, +} + +#[test] +fn parse_arrow_parallel_matches_the_sequential_row_count() { + let mut text = String::from("id,name +"); + for i in 0..200_000 { + text.push_str(&format!("{i},name-{i} +")); + } + let path = std::env::temp_dir().join(format!("xlpa-parallel-{}.csv", std::process::id())); + std::fs::write(&path, text).expect("the csv must be writable"); + let workbook = Workbook::open_with(path.to_str().unwrap(), XL_FORMAT_CSV, None).expect("open must succeed"); + + let sequential = parse_arrow::(workbook.sheet(0).expect("sheet 0"), 1).expect("sequential parse"); + let parallel = parse_arrow_parallel::(workbook.sheet(0).expect("sheet 0"), 1, 0).expect("parallel parse"); + assert_eq!(parallel.num_rows(), sequential.num_rows()); + assert_eq!(parallel.num_rows(), 200_000); + drop(workbook); + let _ = std::fs::remove_file(path); +} diff --git a/rust/excelreader/tests/parse_typed.rs b/rust/excelreader/tests/parse_typed.rs index d83b15e4..d46eea83 100644 --- a/rust/excelreader/tests/parse_typed.rs +++ b/rust/excelreader/tests/parse_typed.rs @@ -1,5 +1,5 @@ -use excelreader::workbook::{parse_sheet, ExcelMapper, Workbook}; -use excelreader::{Date, OpenOptions, XL_FORMAT_XLSB, XL_T_I64, XL_T_STRING}; +use excelreader::workbook::{parse_sheet, parse_sheet_parallel, ExcelMapper, Workbook}; +use excelreader::{Date, OpenOptions, XL_FORMAT_CSV, XL_FORMAT_XLSB, XL_T_DATE, XL_T_I64, XL_T_STRING}; #[derive(Default, ExcelMapper)] struct Row { @@ -208,3 +208,62 @@ fn abi_version_matches_the_loaded_library() { "the linked native library speaks a different ABI revision than this crate" ); } + +fn write_csv(tag: &str, text: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("xlpt-{tag}-{}.csv", std::process::id())); + std::fs::write(&path, text).expect("the csv must be writable"); + path +} + +#[test] +fn infer_schema_parse_text_types_csv_text_fields() { + let path = write_csv("infer", "id,day +1,2024-01-02 +2,2024-03-04 +"); + let workbook = Workbook::open_with(path.to_str().unwrap(), XL_FORMAT_CSV, None).expect("open must succeed"); + let sheet = workbook.sheet(0).expect("sheet 0"); + + let plain = sheet.infer_schema(1, 100).expect("infer_schema must succeed"); + assert!(plain.iter().all(|c| c.column_type == XL_T_STRING)); + + let typed = sheet.infer_schema_parse_text(1, 100).expect("infer_schema_parse_text must succeed"); + assert_eq!(typed[0].column_type, XL_T_I64); + assert_eq!(typed[1].column_type, XL_T_DATE); + drop(workbook); + let _ = std::fs::remove_file(path); +} + +#[derive(Default, ExcelMapper)] +struct CsvRow { + #[excel(name = "id")] + id: i64, + #[excel(name = "name")] + name: String, +} + +#[test] +fn parse_sheet_parallel_matches_the_sequential_parse() { + const ROWS: i64 = 200_000; + let mut text = String::from("id,name +"); + for i in 0..ROWS { + text.push_str(&format!("{i},name-{i} +")); + } + let path = write_csv("parallel", &text); + let workbook = Workbook::open_with(path.to_str().unwrap(), XL_FORMAT_CSV, None).expect("open must succeed"); + + let sequential = parse_sheet::(workbook.sheet(0).expect("sheet 0"), 1).expect("sequential parse"); + let parallel = parse_sheet_parallel::(workbook.sheet(0).expect("sheet 0"), 1, 0).expect("parallel parse"); + assert_eq!(parallel.len(), sequential.len()); + assert_eq!(parallel.len() as i64, ROWS); + for index in [0, parallel.len() - 1] { + let (a, b) = (parallel.get(index).unwrap(), sequential.get(index).unwrap()); + assert_eq!((a.id, a.name), (b.id, b.name)); + } + + assert!(parse_sheet_parallel::(workbook.sheet(0).expect("sheet 0"), 1, -1).is_err()); + drop(workbook); + let _ = std::fs::remove_file(path); +} From ba14afb1aafe369e6ea6f7579bec0ecb37469e40 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:14:25 -0300 Subject: [PATCH 44/49] test(python): reproduce the concurrent-close race Park each closing thread at its first read of the handle so both pass the is-None check; fails every time on the unlocked close, passes with the lock. Drops the stub test, which also passed on the old code. Co-Authored-By: Claude Sonnet 5.5 --- python/tests/test_sheets.py | 51 ++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/python/tests/test_sheets.py b/python/tests/test_sheets.py index 9a77b6ed..aa487f06 100644 --- a/python/tests/test_sheets.py +++ b/python/tests/test_sheets.py @@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from excelreader import ExcelReaderError, Sheet, SheetVisibility, open_bytes, open_writer_to_memory +from excelreader import ExcelReaderError, Sheet, SheetVisibility, Workbook, open_bytes, open_writer_to_memory def _two_sheets() -> bytes: @@ -196,39 +196,44 @@ def test_a_sheet_with_no_rows_reads_empty(): assert list(sheet.rows()) == [] -def test_closing_from_many_threads_at_once_raises_nowhere(): - workbook = open_bytes(_two_sheets()) - barrier = threading.Barrier(32) +class _SlowCheckWorkbook(Workbook): + """Parks each thread at its first read of the handle until two threads have read it.""" + + _gate = threading.Barrier(2) + _seen = threading.local() + + @property + def _handle(self): + value = self._value + if not getattr(self._seen, "done", False): + self._seen.done = True + try: + self._gate.wait(timeout=0.5) + except threading.BrokenBarrierError: + pass + return value + + @_handle.setter + def _handle(self, value): + self._value = value + + +def test_closing_from_two_threads_at_once_raises_nowhere(): + source = open_bytes(_two_sheets()) + workbook = _SlowCheckWorkbook(source._handle) + source._handle = None errors: list[BaseException] = [] def close() -> None: - barrier.wait() try: workbook.close() except BaseException as error: # noqa: BLE001 errors.append(error) - threads = [threading.Thread(target=close) for _ in range(32)] + threads = [threading.Thread(target=close) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join() assert errors == [] - - -def test_closing_twice_calls_native_close_once(monkeypatch): - workbook = open_bytes(_two_sheets()) - real_close = workbook._lib.xl_close - calls = [] - - class StubLib: - def xl_close(self, handle): - calls.append(handle) - return real_close(handle) - - monkeypatch.setattr(workbook, "_lib", StubLib()) - workbook.close() - workbook.close() - - assert len(calls) == 1 From 0b7e42a9ddc9dbad0f29a2d03b616dda9c9f1cf4 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 01:16:35 -0300 Subject: [PATCH 45/49] fix(cpp): build the comparison benchmarks against the v6 wrapper The sources already compile against v6; the compare option failed to configure because xlsxio's C sources need the C language enabled. Co-Authored-By: Claude Sonnet 5.5 --- cpp/benchmarks/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/cpp/benchmarks/CMakeLists.txt b/cpp/benchmarks/CMakeLists.txt index dfe28fab..14fe2fd7 100644 --- a/cpp/benchmarks/CMakeLists.txt +++ b/cpp/benchmarks/CMakeLists.txt @@ -43,6 +43,7 @@ excelreader_copy_native_library(excelreader_cpp_write_benchmarks) # heavier FetchContent builds (their own zip/xml stacks) than the suite above. option(EXCELREADER_BUILD_BENCHMARKS_COMPARE "Build the excelreader-vs-xlnt-vs-xlsxio-vs-libxlsxwriter-vs-duckdb comparison benchmark" OFF) if(EXCELREADER_BUILD_BENCHMARKS_COMPARE) + enable_language(C) set(STATIC ON CACHE BOOL "" FORCE) set(STATIC_CRT OFF CACHE BOOL "" FORCE) set(TESTS OFF CACHE BOOL "" FORCE) From d535ff3927340855275a8dedf4b23195ce3adb7a Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 02:26:58 -0300 Subject: [PATCH 46/49] test(python): let bench_read read any format its path names The benchmark hardcoded format="xlsb", so a path of any other format failed. open_workbook now infers the format from the path; the CSV legs keep their explicit format. Co-Authored-By: Claude Sonnet 5.5 --- python/benchmarks/bench_read.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/python/benchmarks/bench_read.py b/python/benchmarks/bench_read.py index 7f1eea03..9ca3f523 100644 --- a/python/benchmarks/bench_read.py +++ b/python/benchmarks/bench_read.py @@ -41,7 +41,7 @@ def _report(name: str, times: list[float], n: int) -> None: def bench_read_all(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: all_rows = workbook.sheets[0].read_all() row_count = len(all_rows) cell_count = sum(len(row) for row in all_rows) @@ -49,7 +49,7 @@ def bench_read_all(path: Path) -> tuple[int, int]: def bench_rows(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: row_count = 0 cell_count = 0 for row in workbook.sheets[0].rows(): @@ -59,7 +59,7 @@ def bench_rows(path: Path) -> tuple[int, int]: def bench_read_all_columnar(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: sheet = workbook.sheets[0].read_all_columnar() return len(sheet.row_offsets) - 1, len(sheet.columns) @@ -87,13 +87,13 @@ def bench_read_all_columnar(path: Path) -> tuple[int, int]: def bench_parse_typed(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: table = workbook.sheets[0].parse_typed(_FIXTURE_SCHEMA) return table.row_count, table.row_count * len(table.columns) def bench_to_arrow(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: array = workbook.sheets[0].to_arrow(_FIXTURE_SCHEMA) return len(array), len(array) * array.type.num_fields @@ -101,7 +101,7 @@ def bench_to_arrow(path: Path) -> tuple[int, int]: def bench_iter_parse_typed(path: Path) -> tuple[int, int]: """Batched counterpart to bench_parse_typed: one batch resident instead of the whole sheet.""" rows = 0 - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: for table in workbook.sheets[0].iter_parse_typed(_FIXTURE_SCHEMA, batch_size=10000): rows += table.row_count return rows, rows * len(_FIXTURE_SCHEMA) @@ -109,7 +109,7 @@ def bench_iter_parse_typed(path: Path) -> tuple[int, int]: def bench_record_batch_reader(path: Path) -> tuple[int, int]: rows = 0 - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: for batch in workbook.sheets[0].to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): rows += batch.num_rows return rows, rows * len(_FIXTURE_SCHEMA) @@ -148,7 +148,7 @@ def _private_bytes() -> int: def peak_leg_whole_sheet(path: Path) -> tuple[int, int]: """Whole-sheet Arrow read, sampled once while the full table is live. Returns (rows, peak).""" base = _private_bytes() - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: batch = workbook.sheets[0].to_record_batch(_FIXTURE_SCHEMA) peak = _private_bytes() - base rows = batch.num_rows @@ -160,7 +160,7 @@ def peak_leg_streamed(path: Path) -> tuple[int, int]: base = _private_bytes() rows = 0 peak = 0 - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: for batch in workbook.sheets[0].to_record_batch_reader(_FIXTURE_SCHEMA, batch_size=10000): rows += batch.num_rows peak = max(peak, _private_bytes() - base) @@ -196,7 +196,7 @@ def _measure_peaks(path: Path) -> dict[str, int]: def bench_to_polars(path: Path) -> tuple[int, int]: - with excelreader.open_workbook(path, format="xlsb") as workbook: + with excelreader.open_workbook(path) as workbook: schema = workbook.sheets[0].infer_schema(sample_size=10) table = workbook.sheets[0].to_polars(schema) return table.shape[0], table.shape[0] * table.shape[1] From 92b6b37539e3291afc7695518763043a6cb65767 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 02:30:21 -0300 Subject: [PATCH 47/49] perf(native): regenerate the static PGO profile for the v6 entry points The profile predated the v6 row cursor and sheet-index signatures, so ILC found no data for ReadApi.NextRow/NextRowView or the typed entry points. Python, XLSX 65K, alternated old/new/old/new: read_all -1.1%, rows -0.9%, read_all_columnar -1.2%, to_polars -3.5%; CSV parse_typed and to_polars flat. Rust and C++ on XLSB (unprofiled by design): within noise. Co-Authored-By: Claude Sonnet 5.5 --- src/ExcelReader.Native/README.md | 3 --- src/ExcelReader.Native/pgo/excelreader.mibc | Bin 63520 -> 65158 bytes 2 files changed, 3 deletions(-) diff --git a/src/ExcelReader.Native/README.md b/src/ExcelReader.Native/README.md index 7050a302..e9cfa05c 100644 --- a/src/ExcelReader.Native/README.md +++ b/src/ExcelReader.Native/README.md @@ -35,9 +35,6 @@ trace (needs `dotnet-trace` and `dotnet-pgo`): -- "$PWD/tests/ExcelReader.NativePgoTrainer/bin/Release/net10.0/ExcelReader.NativePgoTrainer" dotnet-pgo create-mibc -t pgo.nettrace --exclude-methods 'Xlsb|Biff12' -o src/ExcelReader.Native/pgo/excelreader.mibc -The checked-in profile predates the v6 row cursor: `ReadApi.NextRow` and `ReadApi.NextRowView` changed -signature, so they compile without profile data until the profile is regenerated and re-measured. - The XLSB methods are excluded on purpose: with their profile the XLSB parse got 3% slower, and without it 1.12x faster. Re-measured with the regenerated profile: including them makes XLSB typed parsing ~16% slower. Measure every format before and after replacing the profile. Some formats diff --git a/src/ExcelReader.Native/pgo/excelreader.mibc b/src/ExcelReader.Native/pgo/excelreader.mibc index 0336e582ab24107cf655cc9356c00e4b5174f356..dd5d3a56c56df794ca2c2193d20aa97f911492ac 100644 GIT binary patch literal 65158 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∾{IIGv|b(UrI`i#)bI)sOT(3wFCr(QTc zu~#VfcM6z^WYg&G@t>68_Z85|S2@bzZ;4QP=j1CI$gfG6LxC(FeN9sS>t-&`VTK+* zMbJUXcjx#i-p<5s3g_gXA(LR|Adr$X1@7xj9*f>E;Sbuoka zje&5GlY!)MY7UkLY$`mgUWbgsKO2^ZvSv0(w>qX)HSK|M(|JDt0 zK?w1mF8#-Tk^ldn_>b`K{{~P?0Rj{N6aWAK2mnTeJ6(oU Date: Wed, 7 Oct 2026 02:33:07 -0300 Subject: [PATCH 48/49] build(cpp): configure the comparison benchmarks on CMake 4 xlnt 1.5.0 and zsv declare a CMake minimum that CMake 4 no longer accepts; raise the policy minimum for those fetched projects. Co-Authored-By: Claude Sonnet 5.5 --- cpp/benchmarks/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cpp/benchmarks/CMakeLists.txt b/cpp/benchmarks/CMakeLists.txt index 14fe2fd7..d89c0e34 100644 --- a/cpp/benchmarks/CMakeLists.txt +++ b/cpp/benchmarks/CMakeLists.txt @@ -44,6 +44,7 @@ excelreader_copy_native_library(excelreader_cpp_write_benchmarks) option(EXCELREADER_BUILD_BENCHMARKS_COMPARE "Build the excelreader-vs-xlnt-vs-xlsxio-vs-libxlsxwriter-vs-duckdb comparison benchmark" OFF) if(EXCELREADER_BUILD_BENCHMARKS_COMPARE) enable_language(C) + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) # xlnt 1.5.0 declares a CMake minimum CMake 4 rejects set(STATIC ON CACHE BOOL "" FORCE) set(STATIC_CRT OFF CACHE BOOL "" FORCE) set(TESTS OFF CACHE BOOL "" FORCE) @@ -312,6 +313,7 @@ if(EXCELREADER_BUILD_BENCHMARKS_ZSV) "extensions MSVC cannot compile. On Windows, configure with MinGW-w64 (e.g. MSYS2 UCRT64).") endif() enable_language(C) + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) # a zsv dependency declares a CMake minimum CMake 4 rejects # zsv builds through ./configure + make; its library is the single translation unit src/zsv.c, # so it is compiled directly here instead. From 2d351ebe98ce24c466f56cea90bef7ace3d9a133 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Wed, 7 Oct 2026 08:30:49 -0300 Subject: [PATCH 49/49] Refactor code structure for improved readability and maintainability --- docs/guide/reading.md | 22 +-- docs/performance/benchmarks.md | 242 ++++++++++++++++----------------- 2 files changed, 132 insertions(+), 132 deletions(-) diff --git a/docs/guide/reading.md b/docs/guide/reading.md index cf91604f..ad838027 100644 --- a/docs/guide/reading.md +++ b/docs/guide/reading.md @@ -78,7 +78,7 @@ records that do not exist — deduplicating on a key does not filter those out. See the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. Those figures are `CsvParallel.AggregateAsync`'s, measured over the same projection path this shares: at dop 16 it is -~2.3x faster than the typed path while allocating ~410x less, with zero garbage collections. +~2.8x faster than the typed path while allocating ~410x less, with zero garbage collections. Properties need setters. A get-only property is skipped during binding and silently receives nothing. @@ -96,7 +96,7 @@ using var workbook = Excel.FromXlsxFile("report.xlsx"); Apache.Arrow.RecordBatch batch = workbook.FirstSheet.ToArrowRecordBatch(); ``` -`schema` defaults to `Excel.InferSchema`'s guess; pass an explicit `ExcelColumnSchema[]` to skip inference. The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs ~13% more time than an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. +`schema` defaults to `Excel.InferSchema`'s guess; pass an explicit `ExcelColumnSchema[]` to skip inference. The whole sheet is materialized into one `RecordBatch` — there is no chunked/streaming variant yet. Inference costs about the same time and ~1.8x the allocation of an explicit schema on the same file. See [Arrow conversion](../performance/benchmarks.md#arrow-conversion) in the benchmarks. `WriteRecordBatch`/`WriteRecordBatchAsync` are the write-side mirror — one call from a `RecordBatch` to a sheet, for XLSX, XLSB, XLS, and CSV: @@ -264,16 +264,16 @@ Measured across both read benchmarks (see [Real data reads](../performance/bench | Workload | Default | `PrefetchDecompression = true` | Gain | |---|---:|---:|---:| -| XLSX, real data | 54.1 ms | 31.8 ms | 41% | -| XLSM, real data | 54.0 ms | 31.3 ms | 42% | -| XLSB, real data | 28.9 ms | 16.5 ms | 43% | -| XLSX, string-heavy | 42.2 ms | 28.2 ms | 33% | -| XLSB, string-heavy | 39.4 ms | 25.6 ms | 35% | +| XLSX, real data | 49.7 ms | 27.8 ms | 44% | +| XLSM, real data | 50.3 ms | 28.1 ms | 44% | +| XLSB, real data | 27.7 ms | 16.9 ms | 39% | +| XLSX, string-heavy | 41.2 ms | 27.4 ms | 34% | +| XLSB, string-heavy | 39.5 ms | 26.0 ms | 34% | The gain tracks how much of a read is decompression rather than parsing, so it is largest -on XLSB with numeric data (where inflate dominates). Allocations rise from the producer task and the -pooled decompression buffers — on the real-data corpus, roughly 18 KB to 37 KB for XLSX -and 19 KB to 29 KB for XLSB — and neither path triggers a garbage collection. +on XLSX and XLSM with numeric data (where inflate dominates). Allocations rise from the producer task and the +pooled decompression buffers — on the real-data corpus, roughly 8 KB to 25 KB for XLSX +and 7 KB to 16 KB for XLSB — and neither path triggers a garbage collection. Do **not** enable it for concurrent server workloads: a caller already reading many files in parallel is CPU-saturated, and an extra background thread per read only doubles thread @@ -299,5 +299,5 @@ table.Load(data); The header row (1-based, default 1) fixes the column shape; pass `headerRow: 0` for a header-less sheet, whose columns come back named `Column0`, `Column1`, ... sized from the first data row. There is no schema-inference pass — `GetFieldType`/`GetValue`/the typed getters read the *current* row's own cell type, so a consumer building its schema from the first row (like `DataTable.Load`) locks in that row's types for the whole load. `NextResult()` always returns `false`; build one `ExcelDataReader` per sheet instead of chaining result sets. -The bridge costs ~1.4–1.5x over iterating rows directly, and `GetBytes` stays within ~10% of the direct read. See [ADO.NET bridge](../performance/benchmarks.md#adonet-bridge-idatareader) in the benchmarks. +The bridge costs ~1.3–1.4x over iterating rows directly, and `GetBytes` stays within ~10% of the direct read. See [ADO.NET bridge](../performance/benchmarks.md#adonet-bridge-idatareader) in the benchmarks. diff --git a/docs/performance/benchmarks.md b/docs/performance/benchmarks.md index 02e413c0..e75b68cc 100644 --- a/docs/performance/benchmarks.md +++ b/docs/performance/benchmarks.md @@ -15,54 +15,54 @@ Compares ExcelReader against established XLSX libraries on the same generated wo | Scenario | ExcelReader | Sylvan | OfficeIMO | SpreadCheetah | |---|---:|---:|---:|---:| -| Cell-by-cell read | 7.778 ms, 11.18 KB | 36.633 ms, 1.89 MB | 105.146 ms, 6.84 MB | - | -| Cell-by-cell read async | 8.322 ms, 13.31 KB | - | - | - | -| Typed row parsing | 11.304 ms, 3.87 MB | 56.964 ms, 10.47 MB | 106.258 ms, 5.69 MB | - | -| Typed row parsing async | 11.696 ms, 3.88 MB | 60.071 ms, 10.48 MB | - | - | -| Typed row parsing, shared strings | 11.038 ms, 2.30 MB | - | - | - | -| Workbook writing | 10.910 ms, 4.02 MB | - | 18.042 ms, 4.02 MB | 15.136 ms, 15.84 MB | -| Workbook writing, shared strings | 10.508 ms, 4.06 MB | - | - | - | +| Cell-by-cell read | 7.207 ms, 3.82 KB | 35.203 ms, 1.89 MB | 104.726 ms, 6.84 MB | - | +| Cell-by-cell read async | 7.953 ms, 3.89 KB | - | - | - | +| Typed row parsing | 10.935 ms, 3.87 MB | 56.855 ms, 10.47 MB | 104.530 ms, 5.69 MB | - | +| Typed row parsing async | 11.241 ms, 3.87 MB | 60.501 ms, 10.48 MB | - | - | +| Typed row parsing, shared strings | 10.849 ms, 2.29 MB | - | - | - | +| Workbook writing | 10.913 ms, 4.02 MB | - | 18.166 ms, 4.02 MB | 15.128 ms, 15.84 MB | +| Workbook writing, shared strings | 10.848 ms, 4.06 MB | - | - | - | -ExcelReader is ~4.7x faster than Sylvan for raw XLSX reads, allocating ~173x less, and ~13.5x faster than OfficeIMO. For typed parsing it is ~5.0x faster than Sylvan (~5.1x async against async). For XLSX writing, ExcelReader is ~1.4x faster than SpreadCheetah while allocating ~3.9x less memory, and ~1.7x faster than OfficeIMO. +ExcelReader is ~4.9x faster than Sylvan for raw XLSX reads, allocating ~508x less, and ~14.5x faster than OfficeIMO. For typed parsing it is ~5.2x faster than Sylvan (~5.4x async against async). For XLSX writing, ExcelReader is ~1.4x faster than SpreadCheetah while allocating ~3.9x less memory, and ~1.7x faster than OfficeIMO. OfficeIMO.Excel's typed parsing is hand-mapped from its `OpenDataReader`, because `RowsAs` needs an `r` attribute on every row and cell, which the spec makes optional and ExcelReader's writer omits. -Reading a shared-strings XLSX workbook with typed parsing is ~2% faster than the inline-string sheet above (11.038 ms vs. 11.304 ms) and allocates ~41% less (2.30 MB vs. 3.87 MB) — each distinct string decodes once into the shared-string cache instead of once per cell occurrence. +Reading a shared-strings XLSX workbook with typed parsing is ~1% faster than the inline-string sheet above (10.849 ms vs. 10.935 ms) and allocates ~41% less (2.29 MB vs. 3.87 MB) — each distinct string decodes once into the shared-string cache instead of once per cell occurrence. ### XLSB (BIFF12) | Scenario | ExcelReader | OfficeIMO | |---|---:|---:| -| Cell-by-cell read | 5.409 ms, 13.28 KB | 11.495 ms, 6.70 MB | -| Cell-by-cell read async | 5.756 ms, 15.84 KB | - | -| Typed row parsing | 8.247 ms, 3.88 MB | - | -| Typed row parsing async | 8.319 ms, 3.88 MB | - | -| Workbook writing | 7.555 ms, 4.02 MB | - | -| Workbook writing, shared strings | 7.268 ms, 4.06 MB | - | +| Cell-by-cell read | 5.178 ms, 4.13 KB | 10.406 ms, 6.70 MB | +| Cell-by-cell read async | 5.249 ms, 4.20 KB | - | +| Typed row parsing | 8.211 ms, 3.87 MB | - | +| Typed row parsing async | 8.261 ms, 3.87 MB | - | +| Workbook writing | 7.478 ms, 4.02 MB | - | +| Workbook writing, shared strings | 7.370 ms, 4.06 MB | - | -XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x faster than XLSX reads, typed parsing is ~1.4x faster than XLSX parsing, and writing is ~1.4x faster than XLSX writing. Against OfficeIMO's XLSB reader, ExcelReader is ~2.1x faster while allocating ~516x less. The XLSB writer is also ~2.0x faster than SpreadCheetah's XLSX writer on this benchmark while allocating ~75% less memory. +XLSB is the fastest generated Excel format in these results: raw reads are ~1.4x faster than XLSX reads, typed parsing is ~1.3x faster than XLSX parsing, and writing is ~1.5x faster than XLSX writing. Against OfficeIMO's XLSB reader, ExcelReader is ~2.0x faster while allocating ~1,660x less. The XLSB writer is also ~2.0x faster than SpreadCheetah's XLSX writer on this benchmark while allocating ~75% less memory. ### XLS (BIFF8) | Scenario | ExcelReader | Sylvan | OfficeIMO | |---|---:|---:|---:| -| Cell-by-cell read | 3.315 ms, 2.45 KB | 5.308 ms, 1,717.73 KB | 6.876 ms, 10,499.25 KB | -| Cell-by-cell read async | 3.252 ms, 2.52 KB | - | - | -| Workbook writing | 5.150 ms, 16.03 MB | - | - | +| Cell-by-cell read | 3.916 ms, 2.84 KB | 5.173 ms, 1,717.73 KB | 6.856 ms, 10,499.25 KB | +| Cell-by-cell read async | 3.236 ms, 2.91 KB | - | - | +| Workbook writing | 5.296 ms, 16.03 MB | - | - | -ExcelReader is ~1.6x faster than Sylvan for generated XLS reads while allocating ~700x less memory, and ~2.1x faster than OfficeIMO. The XLS writer is ~2.1x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 5.150 ms vs 10.802 ms in the same run). Most of its 16.03 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.03 MB like the other formats. +ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~605x less memory, and ~1.8x faster than OfficeIMO. The XLS writer is ~2.1x faster than the XLSX writer in this benchmark (`XlsWriteBenchmark`, 5.296 ms vs 10.964 ms in the same run). Most of its 16.03 MB is the benchmark's pre-sized 16 MB destination `MemoryStream`; the typed-record table below measures it at 4.03 MB like the other formats. ### CSV | Scenario | ExcelReader | Sep | Sylvan.Data.Csv | |---|---:|---:|---:| -| Cell-by-cell read | 3.609 ms, 440 B | 7.709 ms, 3.93 KB | 4.495 ms, 37.78 KB | -| Cell-by-cell read async | 2.911 ms, 512 B | - | - | -| Typed row parsing | 5.065 ms, 3.86 MB | 8.880 ms, 3.87 MB | 12.424 ms, 10.95 MB | -| Typed row parsing async | 5.129 ms, 3.86 MB | - | - | -| Row writing | 4.624 ms, 4.00 MB | 6.919 ms, 4.01 MB | 6.940 ms, 4.04 MB | +| Cell-by-cell read | 3.053 ms, 848 B | 8.012 ms, 3.93 KB | 4.359 ms, 37.78 KB | +| Cell-by-cell read async | 2.941 ms, 920 B | - | - | +| Typed row parsing | 4.631 ms, 3.86 MB | 8.454 ms, 3.87 MB | 11.832 ms, 10.95 MB | +| Typed row parsing async | 4.987 ms, 3.86 MB | - | - | +| Row writing | 4.749 ms, 4.00 MB | 7.030 ms, 4.01 MB | 7.276 ms, 4.04 MB | -For raw CSV reads, all three libraries read text through spans. ExcelReader is ~2.1x faster than Sep while allocating ~9x less, and ~1.2x faster than Sylvan.Data.Csv while allocating ~88x less. For typed CSV parsing (the more common case — building actual records), ExcelReader is ~1.8x faster than Sep and ~2.5x faster than Sylvan.Data.Csv, with the lowest allocation of the group. For CSV writing, ExcelReader is ~1.5x faster than both Sep and Sylvan.Data.Csv, which are tied; the ~4 MB shown across all three is primarily the benchmark's pre-sized destination `MemoryStream`, not per-row writer state. +For raw CSV reads, all three libraries read text through spans. ExcelReader is ~2.6x faster than Sep while allocating ~5x less, and ~1.4x faster than Sylvan.Data.Csv while allocating ~46x less. For typed CSV parsing (the more common case — building actual records), ExcelReader is ~1.8x faster than Sep and ~2.6x faster than Sylvan.Data.Csv, with the lowest allocation of the group. For CSV writing, ExcelReader is ~1.5x faster than both Sep and Sylvan.Data.Csv, which are tied; the ~4 MB shown across all three is primarily the benchmark's pre-sized destination `MemoryStream`, not per-row writer state. ### Parallel CSV @@ -70,26 +70,26 @@ For raw CSV reads, all three libraries read text through spans. ExcelReader is ~ | Dop | Conversion-heavy, typed | Narrow ints, typed | Conversion-heavy, `ref struct` aggregate | Narrow ints, `ref struct` aggregate | |---:|---:|---:|---:|---:| -| 1 | 892.6 ms, 670.98 MB | 511.0 ms, 245.32 MB | 802.7 ms, 847.48 KB | 486.8 ms, 842.34 KB | -| 2 | 619.8 ms, 680.07 MB | 390.6 ms, 254.64 MB | 420.9 ms, 1.24 MB | 269.7 ms, 1.29 MB | -| 4 | 360.6 ms, 680.24 MB | 219.3 ms, 254.69 MB | 218.8 ms, 1.35 MB | 137.5 ms, 1.43 MB | -| 8 | 282.3 ms, 680.11 MB | 190.2 ms, 254.59 MB | 148.7 ms, 1.55 MB | 95.0 ms, 1.63 MB | -| 16 | 257.0 ms, 680.31 MB | 188.1 ms, 255.11 MB | 109.8 ms, 1.66 MB | 72.4 ms, 1.71 MB | +| 1 | 904.0 ms, 671.42 MB | 518.6 ms, 245.76 MB | 784.1 ms, 1.20 MB | 478.1 ms, 1.20 MB | +| 2 | 623.7 ms, 680.13 MB | 382.9 ms, 254.68 MB | 405.4 ms, 1.26 MB | 262.9 ms, 1.28 MB | +| 4 | 343.0 ms, 680.24 MB | 217.2 ms, 254.72 MB | 218.3 ms, 1.37 MB | 137.6 ms, 1.44 MB | +| 8 | 285.2 ms, 680.15 MB | 204.7 ms, 254.63 MB | 158.3 ms, 1.54 MB | 94.6 ms, 1.62 MB | +| 16 | 324.9 ms, 680.32 MB | 189.0 ms, 255.15 MB | 114.4 ms, 1.66 MB | 73.0 ms, 1.69 MB | -The typed path scales ~3.5x on the conversion-heavy corpus and ~2.7x on narrow ints, where it stops improving after dop 8 (190.2 ms vs. 188.1 ms at dop 16). Its ~680 MB is one materialized row object per record, and at dop 16 that costs 42,000 Gen0 plus 41,000 Gen1 collections. +The typed path scales ~2.8x on the conversion-heavy corpus (bimodal at dop 16: 86.7 ms standard deviation, median 275.8 ms) and ~2.7x on narrow ints, where it flattens out after dop 4 (217.2 ms vs. 189.0 ms at dop 16). Its ~680 MB is one materialized row object per record, and at dop 16 that costs 42,000 Gen0 plus 41,000 Gen1 collections. -The aggregate columns fold each record into a per-partition accumulator through a `ref struct` model, so text columns stay as `ReadOnlySpan` and no row object is ever materialized. They scale better than the typed path — ~7.3x (conversion-heavy) and ~6.7x (narrow) at dop 16 — because they are not fighting the allocator: at dop 16 the conversion-heavy aggregate is ~2.3x faster than typed while allocating ~410x less (1.66 MB against 680.31 MB), with **zero** garbage collections at every degree. The remaining allocation is per-partition bookkeeping, not per-row. +The aggregate columns fold each record into a per-partition accumulator through a `ref struct` model, so text columns stay as `ReadOnlySpan` and no row object is ever materialized. They scale better than the typed path — ~6.9x (conversion-heavy) and ~6.5x (narrow) at dop 16 — because they are not fighting the allocator: at dop 16 the conversion-heavy aggregate is ~2.8x faster than typed while allocating ~410x less (1.66 MB against 680.32 MB), with **zero** garbage collections at every degree. The remaining allocation is per-partition bookkeeping, not per-row. -Type conversion is the floor under both. A single-threaded pass that allocates nothing still costs 802.7 ms, so parsing bytes into `DateTime`/`decimal`/`int` — not allocation and not I/O — is what the parallelism is actually buying down. +Type conversion is the floor under both. A single-threaded pass that allocates nothing still costs 784.1 ms, so parsing bytes into `DateTime`/`decimal`/`int` — not allocation and not I/O — is what the parallelism is actually buying down. Against [Sep](https://github.com/nietras/Sep)'s own `ParallelEnumerate` (all cores; 8,000,000 narrow rows, 3,000,000 conversion-heavy rows): | Corpus | ExcelReader sequential | ExcelReader parallel | Sep sequential | Sep parallel | |---|---:|---:|---:|---:| -| Narrow ints | 504.3 ms, 244.15 MB | 198.3 ms, 255.11 MB | 508.9 ms, 244.15 MB | 424.6 ms, 247.08 MB | -| Conversion-heavy | 620.6 ms, 467.31 MB | 230.7 ms, 474.77 MB | 823.3 ms, 467.31 MB | 594.1 ms, 469.95 MB | +| Narrow ints | 490.6 ms, 244.15 MB | 187.6 ms, 255.14 MB | 521.7 ms, 244.15 MB | 429.3 ms, 247.77 MB | +| Conversion-heavy | 626.6 ms, 467.31 MB | 184.9 ms, 474.82 MB | 817.5 ms, 467.31 MB | 605.5 ms, 468.84 MB | -Sequentially, ExcelReader's typed parser and Sep are tied on narrow ints (504.3 ms vs. 508.9 ms), and ExcelReader is ~1.3x faster than Sep on the conversion-heavy corpus. In parallel ExcelReader is ~2.1x (narrow) and ~2.6x (conversion-heavy) faster than Sep's, while yielding rows in file order. The conversion-heavy parallel row is bimodal in this run (66.8 ms standard deviation, median 188.9 ms), so its ratio is the least stable number in the table. +Sequentially, ExcelReader's typed parser is ~1.1x faster than Sep on narrow ints (490.6 ms vs. 521.7 ms), and ~1.3x faster on the conversion-heavy corpus. In parallel ExcelReader is ~2.3x (narrow) and ~3.3x (conversion-heavy) faster than Sep's, while yielding rows in file order. ### Real data reads @@ -97,13 +97,13 @@ This benchmark reads a real workbook exported in multiple formats. | Format | ExcelReader | ExcelReader, prefetch | Sylvan | |---|---:|---:|---:| -| XLSX | 54.064 ms, 17.86 KB | 31.842 ms, 37.46 KB | 201.175 ms, 644.23 KB | -| XLSM | 53.988 ms, 17.86 KB | 31.343 ms, 35.83 KB | 207.479 ms, 644.30 KB | -| XLSB | 28.944 ms, 18.80 KB | 16.532 ms, 29.34 KB | 30.223 ms, 338.54 KB | -| XLS | 10.584 ms, 11.30 KB | n/a | 18.765 ms, 185.90 KB | -| CSV | 5.082 ms, 376 B | n/a | 4.611 ms, 40.26 KB | +| XLSX | 49.747 ms, 7.62 KB | 27.818 ms, 25.19 KB | 193.951 ms, 644.23 KB | +| XLSM | 50.311 ms, 7.62 KB | 28.100 ms, 25.01 KB | 197.515 ms, 644.30 KB | +| XLSB | 27.658 ms, 7.18 KB | 16.899 ms, 15.70 KB | 30.150 ms, 338.54 KB | +| XLS | 10.919 ms, 12.02 KB | n/a | 18.306 ms, 185.90 KB | +| CSV | 4.955 ms, 848 B | n/a | 4.491 ms, 40.26 KB | -On this real-data workload, ExcelReader is ~3.7x faster than Sylvan for XLSX and ~3.8x for XLSM, essentially tied for XLSB (~1.04x), and ~1.8x faster for XLS — allocating ~36x less for XLSX and XLSM, ~18x less for XLSB and ~16x less for XLS. On CSV, where both sides read text through spans, Sylvan.Data.Csv is ~10% faster, while ExcelReader allocates ~110x less. The prefetch column is the opt-in [`PrefetchDecompression`](../guide/reading.md#prefetch-decompression-xlsxxlsb) option; XLS and CSV are uncompressed, so it does not apply to them. +On this real-data workload, ExcelReader is ~3.9x faster than Sylvan for XLSX and ~3.9x for XLSM, essentially tied for XLSB (~1.09x), and ~1.7x faster for XLS — allocating ~84x less for XLSX and XLSM, ~47x less for XLSB and ~15x less for XLS. On CSV, where both sides read text through spans, Sylvan.Data.Csv is ~10% faster, while ExcelReader allocates ~49x less. The prefetch column is the opt-in [`PrefetchDecompression`](../guide/reading.md#prefetch-decompression-xlsxxlsb) option; XLS and CSV are uncompressed, so it does not apply to them. ### In-memory real-data reads @@ -111,18 +111,18 @@ The real-data benchmark also measures the in-memory path for workbook content lo | Method | Mean | StdDev | Allocated | |---|---:|---:|---:| -| Xlsx_ExcelReader_Memory | 53.556 ms | 0.628 ms | 7.01 KB | -| Xlsx_ExcelReader_Memory_Prefetch | 31.833 ms | 0.356 ms | 26.23 KB | -| Xlsm_ExcelReader_Memory | 54.460 ms | 0.879 ms | 7.01 KB | -| Xlsm_ExcelReader_Memory_Prefetch | 31.199 ms | 0.209 ms | 25.61 KB | -| Xlsb_ExcelReader_Memory | 28.853 ms | 0.435 ms | 8.69 KB | -| Xlsb_ExcelReader_Memory_Prefetch | 17.263 ms | 0.311 ms | 17.14 KB | -| Xls_ExcelReader_Memory | 10.143 ms | 0.059 ms | 11.30 KB | -| Csv_ExcelReader_Memory | 4.884 ms | 0.055 ms | 312 B | +| Xlsx_ExcelReader_Memory | 49.968 ms | 0.156 ms | 7.38 KB | +| Xlsx_ExcelReader_Memory_Prefetch | 28.667 ms | 0.090 ms | 25.19 KB | +| Xlsm_ExcelReader_Memory | 50.871 ms | 0.172 ms | 7.38 KB | +| Xlsm_ExcelReader_Memory_Prefetch | 28.844 ms | 0.081 ms | 25.07 KB | +| Xlsb_ExcelReader_Memory | 29.321 ms | 0.070 ms | 8.88 KB | +| Xlsb_ExcelReader_Memory_Prefetch | 15.741 ms | 0.027 ms | 19.11 KB | +| Xls_ExcelReader_Memory | 10.514 ms | 0.033 ms | 11.88 KB | +| Csv_ExcelReader_Memory | 4.745 ms | 0.004 ms | 576 B | -`Csv_ExcelReader_Memory` is both faster (4.884 ms vs. 5.082 ms for `Csv_ExcelReader`) and allocates less (312 B vs. 376 B) than its stream twin. +`Csv_ExcelReader_Memory` is both faster (4.745 ms vs. 4.955 ms for `Csv_ExcelReader`) and allocates less (576 B vs. 848 B) than its stream twin. -`Xls_ExcelReader_Memory` is ~4% faster than its stream twin (10.143 ms vs. 10.584 ms for `Xls_ExcelReader`), with identical allocation (11.30 KB both). In earlier runs the in-memory path was ~33% faster, thanks to `BiffCursor` caching the enclosing contiguous sector run; the stream path has since caught up. +`Xls_ExcelReader_Memory` is ~4% faster than its stream twin (10.514 ms vs. 10.919 ms for `Xls_ExcelReader`), with near-identical allocation (11.88 KB vs. 12.02 KB). In earlier runs the in-memory path was ~33% faster, thanks to `BiffCursor` caching the enclosing contiguous sector run; the stream path has since caught up. ### String-heavy reads @@ -130,22 +130,22 @@ The real-data corpus above is mostly numbers and dates — its shared-string tab | Format | ExcelReader | ExcelReader, prefetch | Sylvan | |---|---:|---:|---:| -| XLSX | 42.19 ms, 2.19 MB | 28.22 ms, 2.27 MB | 187.79 ms, 17.41 MB | -| XLSB | 39.43 ms, 2.19 MB | 25.56 ms, 2.26 MB | 61.75 ms, 17.38 MB | +| XLSX | 41.18 ms, 2.18 MB | 27.36 ms, 2.27 MB | 178.73 ms, 17.41 MB | +| XLSB | 39.48 ms, 2.18 MB | 26.04 ms, 2.26 MB | 62.55 ms, 17.38 MB | -Both formats handle this well: ~4.5x and ~1.6x faster than Sylvan for XLSX and XLSB respectively, at roughly ~8x less memory in both cases, with no garbage collections in either configuration. XLSB's shared-string path previously materialized its table eagerly (~27 MB here); `ParseSharedStreaming` brought it in line with the XLSX streaming/pooling path, cutting allocation by ~12x on this workload. +Both formats handle this well: ~4.3x and ~1.6x faster than Sylvan for XLSX and XLSB respectively, at roughly ~8x less memory in both cases, with no garbage collections in either configuration. XLSB's shared-string path previously materialized its table eagerly (~27 MB here); `ParseSharedStreaming` brought it in line with the XLSX streaming/pooling path, cutting allocation by ~12x on this workload. `XlsxSharedStringHotPathBenchmark` splits the XLSX read into stages on the same workbook. The stored rows re-zip it without compression, so they show what parsing costs once inflate is gone: | Stage | Mean | Allocated | |---|---:|---:| -| Whole read | 41.59 ms | 2.19 MB | -| Whole read, prefetch | 28.37 ms | 2.27 MB | -| Shared-string table only (open + first row) | 13.70 ms | 755 KB | -| Whole read, stored | 20.44 ms | 2.19 MB | -| Shared-string table only, stored | 3.14 ms | 754 KB | +| Whole read | 41.17 ms | 2.18 MB | +| Whole read, prefetch | 28.21 ms | 2.27 MB | +| Shared-string table only (open + first row) | 13.54 ms | 747 KB | +| Whole read, stored | 19.82 ms | 2.18 MB | +| Shared-string table only, stored | 3.11 ms | 746 KB | -About half of the default read is inflate: without it the whole read drops from 41.59 ms to 20.44 ms, and the shared-string table from 13.70 ms to 3.14 ms. What remains, ~17 ms, is the worksheet scan. +About half of the default read is inflate: without it the whole read drops from 41.17 ms to 19.82 ms, and the shared-string table from 13.54 ms to 3.11 ms. What remains, ~17 ms, is the worksheet scan. ### Matched-work reads (`*_Materialized`) @@ -157,30 +157,30 @@ Real-data workbook, per format: | Format | Mean | StdDev | Allocated | |---|---:|---:|---:| -| XLSX | 54.55 ms | 0.37 ms | 27.33 KB | -| XLSM | 54.00 ms | 0.16 ms | 27.33 KB | -| XLSB | 30.38 ms | 0.56 ms | 28.27 KB | -| XLS | 12.10 ms | 0.07 ms | 20.77 KB | -| CSV | 19.56 ms | 0.49 ms | 35.71 MB | +| XLSX | 52.42 ms | 0.05 ms | 17.09 KB | +| XLSM | 51.33 ms | 0.07 ms | 17.09 KB | +| XLSB | 30.97 ms | 0.09 ms | 16.65 KB | +| XLS | 12.62 ms | 0.03 ms | 21.48 KB | +| CSV | 18.42 ms | 0.09 ms | 35.71 MB | Generated 50,000-row XLSX workbook: | Scenario | Mean | StdDev | Allocated | |---|---:|---:|---:| -| Cell-by-cell read, materialized | 9.08 ms | 0.30 ms | 1.58 MB | +| Cell-by-cell read, materialized | 8.41 ms | 0.02 ms | 1.58 MB | String-heavy workbook (65,536 rows, ~190,000 distinct shared strings): | Format | Mean | StdDev | Gen0 | Gen1 | Gen2 | Allocated | |---|---:|---:|---:|---:|---:|---:| -| XLSX | 63.39 ms | 1.21 ms | 1,000.0 | 875.0 | 250.0 | 15.26 MB | -| XLSB | 59.95 ms | 0.96 ms | 1,111.1 | 1,000.0 | 333.3 | 15.26 MB | +| XLSX | 61.82 ms | 0.83 ms | 1,111.1 | 1,000.0 | 333.3 | 15.26 MB | +| XLSB | 59.38 ms | 0.92 ms | 1,111.1 | 1,000.0 | 333.3 | 15.26 MB | Reading the allocation columns against the tables above gives the honest shape of the tradeoff: -- **CSV real data:** 35.71 MB materialized against 376 B for the span-based read of the same file. Every CSV field is a distinct string, so nothing dedupes — this is where zero-copy reading earns its keep outright. -- **XLSB real data:** 28.27 KB, essentially cheap. That corpus repeats a small set of values, so the shared-string table dedupes and the reader's string cache materializes each distinct value once. -- **String-heavy XLSX:** 15.26 MB materialized, against **Sylvan's 17.41 MB on the same workload** — doing matched work here, ExcelReader allocates ~12% *less* than Sylvan, with 250 Gen2 collections, and is still ~3.0x faster (63.39 ms vs. 187.79 ms). The same allocation holds for XLSB (15.26 MB vs. Sylvan's 17.38 MB, also ~12% less, 333 Gen2 collections), where matched-work time is ~3% ahead of Sylvan (59.95 ms vs. 61.75 ms). This was previously an inversion (ExcelReader allocated ~1.75x *more* than Sylvan here): the per-reader shared-string dedup cache was an unpresized `Dictionary`, and at ~190,000 distinct values its resize/rehash churn (several of the largest resizes landing on the LOH) accounted for the entire gap — the strings themselves were never the problem, since both readers retain the same ~190,000 distinct instances. Replacing the dictionary with a `string?[]` indexed by shared-string index (sized exactly from the table's known count, no resizing) cut the allocation in half and cut wall-clock time by 14-16% on this benchmark too, since the churn was costing cycles, not just memory. +- **CSV real data:** 35.71 MB materialized against 848 B for the span-based read of the same file. Every CSV field is a distinct string, so nothing dedupes — this is where zero-copy reading earns its keep outright. +- **XLSB real data:** 16.65 KB, essentially cheap. That corpus repeats a small set of values, so the shared-string table dedupes and the reader's string cache materializes each distinct value once. +- **String-heavy XLSX:** 15.26 MB materialized, against **Sylvan's 17.41 MB on the same workload** — doing matched work here, ExcelReader allocates ~12% *less* than Sylvan, with 333 Gen2 collections, and is still ~2.9x faster (61.82 ms vs. 178.73 ms). The same allocation holds for XLSB (15.26 MB vs. Sylvan's 17.38 MB, also ~12% less, 333 Gen2 collections), where matched-work time is ~5% ahead of Sylvan (59.38 ms vs. 62.55 ms). This was previously an inversion (ExcelReader allocated ~1.75x *more* than Sylvan here): the per-reader shared-string dedup cache was an unpresized `Dictionary`, and at ~190,000 distinct values its resize/rehash churn (several of the largest resizes landing on the LOH) accounted for the entire gap — the strings themselves were never the problem, since both readers retain the same ~190,000 distinct instances. Replacing the dictionary with a `string?[]` indexed by shared-string index (sized exactly from the table's known count, no resizing) cut the allocation in half and cut wall-clock time by 14-16% on this benchmark too, since the churn was costing cycles, not just memory. The takeaway is not that one column beats the other: it is that ExcelReader's headline read numbers come from a zero-copy path competitors do not expose, and when it does the same work as them, the gap narrows — and here, with the dedup cache fixed, no longer inverts even at high shared-string cardinality. @@ -190,12 +190,12 @@ The takeaway is not that one column beats the other: it is that ExcelReader's he | Format | Mean | Allocated | |---|---:|---:| -| XLSX | 11.684 ms | 4.02 MB | -| XLSB | 7.552 ms | 4.02 MB | -| XLS | 4.835 ms | 4.03 MB | -| CSV | 4.701 ms | 4.00 MB | +| XLSX | 12.099 ms | 4.02 MB | +| XLSB | 7.597 ms | 4.02 MB | +| XLS | 5.021 ms | 4.03 MB | +| CSV | 4.762 ms | 4.00 MB | -Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 7.552 ms vs. 7.555 ms, XLSX 11.684 ms vs. 10.910 ms). +Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the record-mapping layer adds little overhead over hand-written cell-by-cell writes (XLSB 7.597 ms vs. 7.478 ms, XLSX 12.099 ms vs. 10.913 ms). ### Native and Arrow string writes @@ -204,9 +204,9 @@ Relative ordering: CSV and XLS close at the front, then XLSB, then XLSX — the | Scenario | Mean | Allocated | |---|---:|---:| | Native string columns → XLSX | 20.487 ms | 18.02 KB | -| Native string columns → CSV | 5.053 ms | 416 B | -| Arrow `StringArray` → XLSX | 26.360 ms | 18.06 KB | -| Styled rows → XLSX | 7.136 ms | 18.20 KB | +| Native string columns → CSV | 5.098 ms | 416 B | +| Arrow `StringArray` → XLSX | 26.644 ms | 18.06 KB | +| Styled rows → XLSX | 7.068 ms | 18.20 KB | Text arrives through `IRowWriter.WriteUtf8`, which the XLSX and CSV writers copy through as bytes whenever the text needs no escaping, so none of these allocate per cell: the ~18 KB is per-workbook ZIP and part setup, which CSV does not have. @@ -216,11 +216,11 @@ A `ref struct` model (see [Parse into a ref struct](../guide/parsing.md#parse-in | Target | Mean | Allocated | |---|---:|---:| -| `class` (`ExcelParser`) | 11.30 ms | 3.87 MB | -| `struct` (`ExcelParser`) | 10.97 ms | 1.59 MB | -| `ref struct` + span binding (`ExcelParser`) | 9.99 ms | 11.72 KB | +| `class` (`ExcelParser`) | 10.94 ms | 3.87 MB | +| `struct` (`ExcelParser`) | 9.93 ms | 1.58 MB | +| `ref struct` + span binding (`ExcelParser`) | 9.30 ms | 4.36 KB | -Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.7% less than the `class` baseline — and is ~9% faster than the `struct` target, since there's no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser.FromAttributes`). It can be consumed with `foreach` or `await foreach` but not through `IEnumerable`/`IAsyncEnumerable`/LINQ — a `ref struct` element can't be boxed through those interfaces. +Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.9% less than the `class` baseline — and is ~6% faster than the `struct` target, since there's no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser.FromAttributes`). It can be consumed with `foreach` or `await foreach` but not through `IEnumerable`/`IAsyncEnumerable`/LINQ — a `ref struct` element can't be boxed through those interfaces. ### ADO.NET bridge (`IDataReader`) @@ -228,13 +228,13 @@ Parsing into a `ref struct` with a `ReadOnlySpan` text column removes esse | Access | Mean | Ratio | Allocated | |---|---:|---:|---:| -| Raw rows (`row[i].Value`, baseline) | 7.485 ms | 1.00 | 11.18 KB | -| `GetBytes` (text column only) | 7.628 ms | 1.02 | 11.87 KB | -| `GetValue` (every column) | 10.550 ms | 1.41 | 5.02 MB | -| Typed getters (`GetString`/`GetInt32`/`GetDateTime`/`GetDouble`) | 10.850 ms | 1.45 | 1.59 MB | -| `DataTable.Load` | 61.443 ms | 8.21 | 22.85 MB | +| Raw rows (`row[i].Value`, baseline) | 6.878 ms | 1.00 | 3.87 KB | +| `GetBytes` (text column only) | 7.222 ms | 1.05 | 4.55 KB | +| `GetValue` (every column) | 9.660 ms | 1.40 | 5.01 MB | +| Typed getters (`GetString`/`GetInt32`/`GetDateTime`/`GetDouble`) | 9.059 ms | 1.32 | 1.58 MB | +| `DataTable.Load` | 63.615 ms | 9.25 | 22.84 MB | -The bridge costs ~1.4–1.5x over reading rows directly. `GetValue` boxes every value (the 5 MB), and the typed getters allocate only the text column's `string`s. `GetBytes` copies UTF-8 without making a `string`, so it stays within ~2% of the raw read. `DataTable.Load` is dominated by `DataTable`'s own row storage: the same values cost 10.550 ms to read through `GetValue` alone. +The bridge costs ~1.3–1.4x over reading rows directly. `GetValue` boxes every value (the 5 MB), and the typed getters allocate only the text column's `string`s. `GetBytes` copies UTF-8 without making a `string`, so it stays within ~5% of the raw read. `DataTable.Load` is dominated by `DataTable`'s own row storage: the same values cost 9.660 ms to read through `GetValue` alone. ### Arrow conversion @@ -242,12 +242,12 @@ The bridge costs ~1.4–1.5x over reading rows directly. `GetValue` boxes every | Scenario | Mean | Allocated | |---|---:|---:| -| CSV, typed, explicit schema | 11.01 ms | 8.13 MB | -| CSV, typed, inferred schema | 12.44 ms | 14.89 MB | -| XLSB, typed, explicit schema | 16.47 ms | 8.15 MB | -| CSV, 8 text columns, explicit schema | 19.92 ms | 16.27 MB | +| CSV, typed, explicit schema | 10.76 ms | 8.13 MB | +| CSV, typed, inferred schema | 10.77 ms | 14.89 MB | +| XLSB, typed, explicit schema | 15.85 ms | 8.14 MB | +| CSV, 8 text columns, explicit schema | 17.22 ms | 16.27 MB | -On the same file, letting `Excel.InferSchema` guess the schema costs ~13% more time and ~1.8x the allocation of passing an explicit `ExcelColumnSchema[]`. The allocation grows with the sheet, because the batch holds every row at once and there is no streaming variant. +On the same file, letting `Excel.InferSchema` guess the schema costs about the same time (10.77 ms vs. 10.76 ms) and ~1.8x the allocation of passing an explicit `ExcelColumnSchema[]`. The allocation grows with the sheet, because the batch holds every row at once and there is no streaming variant. ### Encrypted workbooks @@ -255,16 +255,16 @@ On the same file, letting `Excel.InferSchema` guess the schema costs ~13% more t | Scenario | Mean | Allocated | |---|---:|---:| -| Small, plain | 0.184 ms | 19.42 KB | -| Small, encrypted, stream | 26.272 ms | 48.52 KB | -| Small, encrypted, stream, `VerifyEncryptedIntegrity` | 25.905 ms | 50.73 KB | -| Small, encrypted, in memory | 26.139 ms | 53.13 KB | -| Small, encrypted, open only | 25.982 ms | 43.06 KB | -| 300,000 rows, plain | 45.711 ms | 11.18 KB | -| 300,000 rows, encrypted | 76.240 ms | 37.10 KB | -| 300,000 rows, encrypted, open only | 25.998 ms | 36.51 KB | +| Small, plain | 0.198 ms | 9.15 KB | +| Small, encrypted, stream | 26.129 ms | 38.39 KB | +| Small, encrypted, stream, `VerifyEncryptedIntegrity` | 25.641 ms | 40.54 KB | +| Small, encrypted, in memory | 26.001 ms | 53.53 KB | +| Small, encrypted, open only | 25.780 ms | 32.72 KB | +| 300,000 rows, plain | 39.180 ms | 3.87 KB | +| 300,000 rows, encrypted | 66.278 ms | 29.79 KB | +| 300,000 rows, encrypted, open only | 25.744 ms | 29.10 KB | -Nearly all of the cost is a fixed ~26 ms paid when the workbook is opened: deriving the key through 100,000 SHA-512 iterations, which the format requires so that passwords are slow to brute-force. It does not depend on file size (open-only is 25.98 ms for 15 KB and 26.00 ms for 300,000 rows). After that, decrypting while reading costs ~10% over a plain read: 76.24 ms − 26.00 ms = 50.24 ms, against 45.71 ms unencrypted. On the small fixture, integrity verification and the in-memory path cost nothing measurable. The fixture is too small to show the extra pass that verification makes over a large stream. +Nearly all of the cost is a fixed ~26 ms paid when the workbook is opened: deriving the key through 100,000 SHA-512 iterations, which the format requires so that passwords are slow to brute-force. It does not depend on file size (open-only is 25.78 ms for 15 KB and 25.74 ms for 300,000 rows). After that, decrypting while reading costs ~3% over a plain read: 66.28 ms − 25.74 ms = 40.53 ms, against 39.18 ms unencrypted. On the small fixture, integrity verification and the in-memory path cost nothing measurable. The fixture is too small to show the extra pass that verification makes over a large stream. ### Cold start @@ -272,8 +272,8 @@ First use of `ExcelParser.FromAttributes`/`ExcelRecordLayout.FromAttributes`) | 33.17 ms | 28.43 KB | -| First fluent parse (`ExcelParser.Build`) | 28.47 ms | 31.37 KB | -| First fluent parse (`BuildWithAttributeFallback`) | 35.96 ms | 33.49 KB | +| First typed parse (`ExcelParser.FromAttributes`) | 40.69 ms | 21.09 KB | +| First fluent parse (`ExcelParser.Build`) | 36.11 ms | 24.03 KB | +| First fluent parse (`BuildWithAttributeFallback`) | 46.15 ms | 26.16 KB | -`Build` is ~14% faster than the reflection-based parser here; `BuildWithAttributeFallback` is slightly slower than reflection (~8%), since it runs the same `TypeMapper.GetInfo()` path plus the fluent build on top. +`Build` is ~11% faster than the reflection-based parser here; `BuildWithAttributeFallback` is slower than reflection (~13%, with a 5.4 ms standard deviation), since it runs the same `TypeMapper.GetInfo()` path plus the fluent build on top. Run the benchmarks locally: @@ -299,25 +299,25 @@ dotnet run --project tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj | Entry point | Shape handed back | Mean | Managed allocated | |---|---|---:|---:| -| `xl_next_row` | one row per call, serialized into a caller buffer | 41.62 ms | 3.71 MB | -| `xl_read_all_blob` | whole sheet in one buffer | 44.18 ms | 21.06 MB | -| `xl_read_all_decoded` | whole sheet as native row structs | 56.87 ms | 5.71 MB | -| `xl_parse_typed` | typed native columns | 42.20 ms | 11.61 MB | -| `xl_parse_arrow` | Arrow C Data Interface arrays | 41.51 ms | 11.62 MB | +| `xl_next_row` | one row per call, serialized into a caller buffer | 40.35 ms | 3.71 MB | +| `xl_read_all_blob` | whole sheet in one buffer | 43.59 ms | 21.06 MB | +| `xl_read_all_decoded` | whole sheet as native row structs | 54.56 ms | 5.71 MB | +| `xl_parse_typed` | typed native columns | 40.79 ms | 11.61 MB | +| `xl_parse_arrow` | Arrow C Data Interface arrays | 39.51 ms | 11.62 MB | The allocation column is **managed memory only**. `MemoryDiagnoser` cannot see the native blocks that `xl_read_all_decoded`, `xl_parse_typed` and `xl_parse_arrow` return their data in. -Row by row, `xl_next_row` costs ~1.4x the managed in-memory read of the same file (41.62 ms vs. 28.853 ms for `Xlsb_ExcelReader_Memory` under [In-memory real-data reads](#in-memory-real-data-reads); different benchmark class, same run). Every cell crosses the ABI as text, so each binary XLSB number is formatted to UTF-8 on the way out. `xl_read_all_blob` adds ~6% over it, because the whole sheet is buffered before it is copied out. `xl_read_all_decoded` adds ~37%, because it allocates a native block per row. `xl_parse_typed` and `xl_parse_arrow` cost about the same as a row-by-row read but return columnar native buffers with numbers still binary, so they are the path to use for bulk typed loads. +Row by row, `xl_next_row` costs ~1.4x the managed in-memory read of the same file (40.35 ms vs. 29.321 ms for `Xlsb_ExcelReader_Memory` under [In-memory real-data reads](#in-memory-real-data-reads); different benchmark class, same run). Every cell crosses the ABI as text, so each binary XLSB number is formatted to UTF-8 on the way out. `xl_read_all_blob` adds ~8% over it, because the whole sheet is buffered before it is copied out. `xl_read_all_decoded` adds ~35%, because it allocates a native block per row. `xl_parse_typed` and `xl_parse_arrow` cost about the same as a row-by-row read but return columnar native buffers with numbers still binary, so they are the path to use for bulk typed loads. `ChunkedParseBenchmark` compares `xl_parse_typed` on a whole sheet against draining the same sheet in batches through `xl_typed_reader_open`/`xl_typed_reader_next`. It uses the generated 50,000-row XLSX file, read from disk, with three nullable columns (string, int64, float64): | Mode | Mean | Managed allocated | |---|---:|---:| -| Whole sheet (`xl_parse_typed`) | 9.79 ms | 1.31 MB | -| Batches of 10,000 rows | 9.57 ms | 1.60 MB | -| Batches of 1,000 rows | 9.68 ms | 1.51 MB | +| Whole sheet (`xl_parse_typed`) | 8.73 ms | 1.29 MB | +| Batches of 10,000 rows | 8.49 ms | 1.59 MB | +| Batches of 1,000 rows | 8.47 ms | 1.51 MB | -Batching costs nothing in time: both batch sizes land within ~2% of one whole-sheet table. A caller can bound its native memory to one batch at a time without paying for it. +Batching costs nothing in time: both batch sizes land within ~3% of one whole-sheet table. A caller can bound its native memory to one batch at a time without paying for it. ### C++ and Rust bindings