diff --git a/README.md b/README.md index 539ffc07..201ee475 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Relative ordering matches the lower-level writers above (XLS fastest, then XLSB, | `struct` (`ExcelParser`) | 15.10 ms | 1.59 MB | | `ref struct` + span binding (`RefParser.ParseNamed`) | 12.91 ms | 17.17 KB | -Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.6% less than the `class` baseline — and is ~15% faster, since there's no per-row model allocation and no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser`) and is sync-only: a `ref struct` element type cannot appear in `IAsyncEnumerable`, so this has no async counterpart, permanently. +Parsing into a `ref struct` with a `ReadOnlySpan` text column removes essentially all per-row allocation — ~99.6% less than the `class` baseline — and is ~15% faster, since there's no per-row model allocation and no per-row `string` allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as `ExcelParser`). 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. ### Cold start @@ -189,12 +189,24 @@ CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `SheetName == ""`) ## Read asynchronously -`Row` and `Cell` are `ref struct` types, so async reading uses a manual loop instead of `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 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. ```csharp using ExcelReader.Core.Reader; +await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken); + +await foreach (var row in reader) +{ + Console.WriteLine(row[0].GetString()); +} +``` + +`await foreach` binds to the reader's `GetAsyncEnumerator()` by pattern — the sheet is opened synchronously and only each row advance is awaited. 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. + +When you need the sheet *opened* asynchronously too (e.g. the first read touches the network), or you need to `await` while a row is in scope, drive the enumerator manually via `GetAsyncEnumeratorAsync`, which awaits the open and threads the cancellation token: + +```csharp await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken); await using var rows = await reader.GetAsyncEnumeratorAsync(cancellationToken); @@ -205,6 +217,8 @@ while (await rows.MoveNextAsync()) } ``` +`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. Pass the token at open time (as above), or use the manual `GetAsyncEnumeratorAsync(ct)` loop. + ## Parse typed rows `ExcelParser` maps worksheet columns to the public settable properties of `T`. Columns match on the property name, or on `[ExcelColumn("header")]` aliases — repeat the attribute to accept several headers. The first row is the header by default. @@ -330,11 +344,21 @@ foreach (ChangeRowRef item in RefParser.ParseNamed(reader)) } ``` +The sequence also supports `await foreach`, 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.FromFileAsync("changes.xlsx"); + +await foreach (ChangeRowRef item in RefParser.ParseNamed(reader)) +{ + Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}"); +} +``` + A few differences from `ExcelParser`: -- **Span fields alias the reader's row buffer** — valid only until the next row. Copy them out (e.g. `Encoding.UTF8.GetString(span)`) if you need to keep the value past the loop body. -- **Sync only, permanently.** `IAsyncEnumerable` cannot have a `ref struct` element type, so there is no async counterpart — not a missing feature, a language limitation. -- **`foreach` only.** The returned sequence cannot be consumed through `IEnumerable`/LINQ — a `ref struct` element can't be boxed through that interface — so iterate it directly. +- **Span fields alias the reader's row buffer** — valid only until the next row. Copy them out (e.g. `Encoding.UTF8.GetString(span)`) if you need to keep the value past the loop body. Under `await foreach`, the same rule means the model can't be held across an `await` in the loop body. +- **`foreach` / `await foreach` only.** Consumption is pattern-based — the sequence cannot be surfaced through `IEnumerable`, `IAsyncEnumerable`, or LINQ, because a `ref struct` element can't be boxed through those interfaces (`IAsyncEnumerable` in particular forbids a `ref struct` element type — CS9267). Iterate it directly. - **Not AOT/trim-safe**, same tradeoff as `ExcelParser` (both reflect over `T`'s properties and compile setters at runtime). - A regular `struct`/`class` model works with `ParseNamed` too — only a genuine `ref struct` model gets the extra zero-copy span-property binding. diff --git a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs index 68cb6b8a..cd3c2288 100644 --- a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs @@ -54,6 +54,8 @@ IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken can return GetAsyncEnumerator(cancellationToken); } + [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "Async enumerator requires a class to host the async state machine.")] public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { TypeMapInfo info = TypeMapper.GetCsvInfo(); diff --git a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs index 98377dd9..a5443331 100644 --- a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs +++ b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs @@ -39,11 +39,33 @@ internal NamedRefRowEnumerable( _context = new ExcelRowContext(reader.IsDate1904, formatProvider ?? CultureInfo.InvariantCulture); } - // Zero-allocation struct enumerator — the supported way to consume this sequence. + // The supported way to consume this sequence via foreach. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "HLQ006:GetEnumerator should return a value type", + Justification = "The enumerator is a class so the same type can also expose MoveNextAsync for the async path.")] public NamedRefRowEnumerator GetEnumerator() { - return new NamedRefRowEnumerator( - _reader.GetEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow); + return new(_reader.GetEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow); + } + + // The 'await foreach' entry point: C#'s pattern-based async binding picks up this parameterless + // GetAsyncEnumerator() (the enumerator it returns has MoveNextAsync/Current/DisposeAsync). Opens + // the sheet synchronously — the reader's GetAsyncEnumerator() is a sync open (no I/O await), the + // async work is per-row via MoveNextAsync. A ref-struct TModel can't be surfaced through + // IAsyncEnumerable (CS9267), so this stays a pattern match, never the interface. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "The enumerator is a class so the same type can also expose MoveNextAsync for the async path.")] + public NamedRefRowEnumerator GetAsyncEnumerator() + { + return new(_reader.GetAsyncEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow); + } + + // Manual-use alternative that opens the sheet asynchronously (awaits the reader's async open). + // Not reachable by 'await foreach' — its shape (returning a ValueTask of the enumerator) doesn't + // match the pattern. Await it, then drive the returned enumerator with MoveNextAsync in a loop. + public async ValueTask> GetAsyncEnumeratorAsync(CancellationToken ct = default) + { + var enumerator = await _reader.GetAsyncEnumeratorAsync(ct).ConfigureAwait(false); + return new(enumerator, _context, _typeInfo, _comparer, _normalization, _headerRow); } IEnumerator IEnumerable.GetEnumerator() diff --git a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs index 9fcaae07..e3788bd6 100644 --- a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs +++ b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs @@ -16,11 +16,17 @@ namespace ExcelReader.Core.Parser.Internal // call, immediately writing into a caller-supplied `ref T model`. Every existing caller stores // that result in a class FIELD (SyncRowEnumerator.CurrentValue) — illegal for a // ref-struct-constrained T (CS8345: a ref struct field is only legal inside another ref struct). - // So this type splits the same two responsibilities instead: MoveNext() only advances/classifies - // (skip pre-header rows, build the column map once at the header row), and Current's getter parses - // the row into a fresh LOCAL model on every access — never stored as a field, safe to call - // repeatedly (idempotent) for the same row. - public struct NamedRefRowEnumerator : IDisposable + // So this type splits the same two responsibilities instead: MoveNext()/MoveNextAsync() only + // advance/classify (skip pre-header rows, build the column map once at the header row), and Current's + // getter parses the row into a fresh LOCAL model on every access — never stored as a field, safe to + // call repeatedly (idempotent) for the same row. + // + // A reference type (not a struct): MoveNextAsync's awaiting slow path mutates enumeration state + // (_rowNumber, and the one-time column map) across an await, which a struct would silently lose to the + // async state machine's by-value `this` copy. A class shares the one instance, exactly like + // AsyncRowEnumerator. The lazy-Current design above is still required regardless: + // a ref-struct TModel can never be stored in a field (CS8345), class or struct. + public sealed class NamedRefRowEnumerator : IDisposable, IAsyncDisposable where TModel : allows ref struct where TEnumerator : class, IExcelRowEnumerator { @@ -55,7 +61,7 @@ internal NamedRefRowEnumerator( } // Recomputed on every access (see class remarks) — never cached in a field. - public readonly TModel Current + public TModel Current { get { @@ -85,6 +91,63 @@ public bool MoveNext() return false; } + // Async twin of MoveNext, mirroring AsyncRowEnumerator.MoveNextAsync: a non-async + // fast path that stays synchronous whenever the underlying row-enumerator resolves synchronously + // (the common case — no second state machine on top of _rows' own), only falling to an awaiting + // continuation on a genuine buffer miss. Every state mutation (ClassifyRow's ref _rowNumber, + // BuildColumnMap) runs on the shared class instance, so it survives the await (see class remarks). + [SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task", + Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")] + [SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks", + Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")] + public ValueTask MoveNextAsync() + { + while (true) + { + ValueTask moveTask = _rows.MoveNextAsync(); + if (!moveTask.IsCompletedSuccessfully) + { + return AwaitThenContinueAsync(moveTask); + } + if (!moveTask.Result) + { + return new ValueTask(false); + } + switch (ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _bindings is not null)) + { + case ProjectionStep.Yield: + return new ValueTask(true); + case ProjectionStep.BuildMap: + BuildColumnMap(_rows.Current); + break; + case ProjectionStep.Stop: + return new ValueTask(false); + // Skip: loop again, still synchronous. + } + } + } + + private async ValueTask AwaitThenContinueAsync(ValueTask pendingMoveNext) + { + if (!await pendingMoveNext.ConfigureAwait(false)) + { + return false; + } + ProjectionStep step = ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _bindings is not null); + switch (step) + { + case ProjectionStep.Yield: + return true; + case ProjectionStep.BuildMap: + BuildColumnMap(_rows.Current); + break; // map built at the header row — resume the fast path for the next row. + case ProjectionStep.Stop: + return false; + // Skip: resume the fast path. + } + return await MoveNextAsync().ConfigureAwait(false); + } + private void BuildColumnMap(Row row) { int propertyCount = _typeInfo.PropertyCount; @@ -143,7 +206,7 @@ private void BuildColumnMap(Row row) _seen = requireValueCount > 0 ? new bool[bindings.Length] : []; } - private readonly void ParseCurrentRow(Row row, ref TModel model) + private void ParseCurrentRow(Row row, ref TModel model) { NamedColumnBinding[] bindings = _bindings!; bool track = _requireValueCount > 0; @@ -187,21 +250,31 @@ private readonly void ParseCurrentRow(Row row, ref TModel model) } } - private readonly void ValidateRowValues(NamedColumnBinding[] bindings) + private void ValidateRowValues(NamedColumnBinding[] bindings) { for (int i = 0; i < bindings.Length; i++) { - if (bindings[i].RequireValue && !_seen[i]) + ref readonly var binding = ref bindings[i]; + if (binding.RequireValue && !_seen[i]) { - throw ProjectionRules.MissingRequiredValue(bindings[i].Name, _rowNumber); + throw ProjectionRules.MissingRequiredValue(binding.Name, _rowNumber); } } } - public readonly void Dispose() + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "_rows is created for this enumerator alone by NamedRefRowEnumerable.Get(Async)Enumerator() — owned here, not injected.")] + public void Dispose() { _rows.Dispose(); } + + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "_rows is created for this enumerator alone by NamedRefRowEnumerable.Get(Async)Enumerator() — owned here, not injected.")] + public ValueTask DisposeAsync() + { + return _rows.DisposeAsync(); + } } } #endif diff --git a/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs b/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs index 94b0b7e7..829f4bf0 100644 --- a/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs +++ b/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs @@ -47,6 +47,8 @@ public bool MoveNext() private protected abstract ProjectionStep Project(); + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "Rows is created for this enumerator alone by the enclosing enumerable's GetEnumerator (reader.GetEnumerator()) — owned here, not injected.")] public void Dispose() { Rows.Dispose(); diff --git a/src/ExcelReader.Core/Reader/CsvReader.cs b/src/ExcelReader.Core/Reader/CsvReader.cs index af4d7bfb..fce4e247 100644 --- a/src/ExcelReader.Core/Reader/CsvReader.cs +++ b/src/ExcelReader.Core/Reader/CsvReader.cs @@ -97,6 +97,18 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator() return GetEnumerator(); } + [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for the async path.")] + public Enumerator GetAsyncEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator() + { + return GetAsyncEnumerator(); + } + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")] [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs index e67699ae..1911e6f2 100644 --- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs +++ b/src/ExcelReader.Core/Reader/IExcelRowReader.cs @@ -7,6 +7,7 @@ public interface IExcelRowReader { bool IsDate1904 { get; } TEnumerator GetEnumerator(); + TEnumerator GetAsyncEnumerator(); ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = default); } diff --git a/src/ExcelReader.Core/Reader/XlsReader.cs b/src/ExcelReader.Core/Reader/XlsReader.cs index a2d2d89c..f15a7b2d 100644 --- a/src/ExcelReader.Core/Reader/XlsReader.cs +++ b/src/ExcelReader.Core/Reader/XlsReader.cs @@ -103,17 +103,37 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator() [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for parity with XlsxReader.")] - public Enumerator GetAsyncEnumerator(CancellationToken ct = default) + public Enumerator GetAsyncEnumerator() + { + return GetEnumerator(); + } + + // ct overload takes no default value: the parameterless GetAsyncEnumerator() above already covers + // the no-argument call, so a default here would only shadow it. + [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for parity with XlsxReader.")] + public Enumerator GetAsyncEnumerator(CancellationToken ct) { ct.ThrowIfCancellationRequested(); return new Enumerator(this, _sheets[_current].Offset, ct); } + IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator() + { + return GetAsyncEnumerator(); + } + + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")] public ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = default) { return new ValueTask(GetAsyncEnumerator(ct)); } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")] + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", + Justification = "XlsReader is fully in-memory; opening the enumerator is synchronous, so there is no async open to await here.")] ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) { return new ValueTask(GetAsyncEnumerator(ct)); diff --git a/src/ExcelReader.Core/Reader/XlsbReader.cs b/src/ExcelReader.Core/Reader/XlsbReader.cs index fa534480..742b2c90 100644 --- a/src/ExcelReader.Core/Reader/XlsbReader.cs +++ b/src/ExcelReader.Core/Reader/XlsbReader.cs @@ -165,6 +165,18 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator() return GetEnumerator(); } + [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for the async path.")] + public Enumerator GetAsyncEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator() + { + return GetAsyncEnumerator(); + } + public async ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = default) { var entry = WorkbookLookups.GetWorksheetEntry(_zip!, _sheets!, _current); diff --git a/src/ExcelReader.Core/Reader/XlsxReader.cs b/src/ExcelReader.Core/Reader/XlsxReader.cs index 720f14a9..3ea9f570 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.cs @@ -131,6 +131,18 @@ IExcelRowEnumerator IExcelRowReader.GetEnumerator() return GetEnumerator(); } + [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type", + Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for the async path.")] + public Enumerator GetAsyncEnumerator() + { + return GetEnumerator(); + } + + IExcelRowEnumerator IExcelRowReader.GetAsyncEnumerator() + { + return GetAsyncEnumerator(); + } + /// /// Streaming async enumerator over the current sheet. Use with a manual loop — Current /// is a ref struct (Row), so await foreach cannot bind it: diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index 109404aa..abb44f13 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -66,6 +66,11 @@ internal static void WriteString(BiffBuffer xml, string value, int columnIndex, xml.Write(""u8); } + private static bool HasEdgeWhitespace(string value) + { + return value.Length != 0 && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])); + } + internal static void WriteSharedString(BiffBuffer xml, int sharedStringIndex, int columnIndex, int rowNumber, bool includeReference) { WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? " t=\"s\">"u8 : ""u8); @@ -95,11 +100,6 @@ internal static void WriteNumber(BiffBuffer xml, T value, int columnIndex, in xml.Write(""u8); } - private static bool HasEdgeWhitespace(string value) - { - return value.Length != 0 && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])); - } - internal static void WriteNumber(BiffBuffer xml, int value, int columnIndex, int rowNumber, bool includeReference) { WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); diff --git a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs index d8c2905d..7b99de5c 100644 --- a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs +++ b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs @@ -47,6 +47,8 @@ internal static void ValidateSheetName(string name) // paths. internal static class ZipArchiveDisposal { + [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", + Justification = "Disposal helper: disposing the caller-owned ZipArchive is its sole purpose — the two ZIP-backed writers delegate their own zip's disposal here.")] internal static ValueTask DisposeAsync(ZipArchive zip) { #if NET10_0_OR_GREATER diff --git a/src/ExcelReader.Core/Writer/WorkbookRecordWriter.cs b/src/ExcelReader.Core/Writer/WorkbookRecordWriter.cs index bf194bdb..523c00a8 100644 --- a/src/ExcelReader.Core/Writer/WorkbookRecordWriter.cs +++ b/src/ExcelReader.Core/Writer/WorkbookRecordWriter.cs @@ -130,6 +130,8 @@ public static async ValueTask // instead of an interface dispatch. Numeric properties go to the generic Write (a number cell) // everything non-numeric/non-primitive is written as its ToString() text, since Write only // produces valid numeric cells. + [SuppressMessage("Major Code Smell", "S2743:Static fields should not be used in generic types", + Justification = "The per-closed-type static IS the design: headers/property plan are cached once per T, not shared across different T.")] internal static class RecordColumns { private static readonly PropertyInfo[] _props = FilterProperties(); @@ -239,6 +241,8 @@ private static Action Build() // Reflection resolved once per concrete TRow (RowWriter/XlsbRowWriter/XlsRowWriter — at most a // handful of instantiations for the whole process): the Write overloads declared on that concrete // type, plus the set of numeric property types that map to Write. + [SuppressMessage("Major Code Smell", "S2743:Static fields should not be used in generic types", + Justification = "The per-closed-type static IS the design: the resolved MethodInfo set is cached once per concrete TRow, not shared across different TRow.")] internal static class RowWriteMethods where TRow : IRowWriter { private readonly record struct MethodInfoSet(MethodInfo Str, MethodInfo Bool, MethodInfo BoolN, MethodInfo Date, diff --git a/tests/ExcelReader.Tests/CsvReaderTests.cs b/tests/ExcelReader.Tests/CsvReaderTests.cs index dd32c512..dfdaf96a 100644 --- a/tests/ExcelReader.Tests/CsvReaderTests.cs +++ b/tests/ExcelReader.Tests/CsvReaderTests.cs @@ -43,6 +43,24 @@ private static async Task> ReadAllAsync(CsvReader reader) return rows; } + // Consumes the reader through 'await foreach', which binds by pattern to its GetAsyncEnumerator() + // (synchronous open, then MoveNextAsync per row) — the whole reason that method exists. Row is a + // ref struct, so the body must read it without awaiting (it never lives across the loop's await). + private static async Task> ReadAllViaAsyncEnumerator(CsvReader reader) + { + var rows = new List(); + await foreach (var row in reader) + { + var cells = new string[row.ColumnCount]; + for (int i = 0; i < row.ColumnCount; i++) + { + cells[i] = row[i].GetString(); + } + rows.Add(cells); + } + return rows; + } + private static string[] ToArray(CsvReader.Enumerator e) { var row = e.Current; @@ -80,6 +98,19 @@ public async Task SimpleRowsAreReadAsync() Assert.Equal(["1", "2", "3"], rows[1]); } + [Fact] + public async Task SimpleRowsAreReadViaAsyncEnumerator() + { + using var ms = Csv("a,b,c\n1,2,3\n"); + using var reader = Excel.FromCsv(ms); + + var rows = await ReadAllViaAsyncEnumerator(reader); + + Assert.Equal(2, rows.Count); + Assert.Equal(["a", "b", "c"], rows[0]); + Assert.Equal(["1", "2", "3"], rows[1]); + } + [Fact] public void DelimiterInsideQuotesIsLiteral() { diff --git a/tests/ExcelReader.Tests/RefParserTests.cs b/tests/ExcelReader.Tests/RefParserTests.cs index 21abf168..18342c6e 100644 --- a/tests/ExcelReader.Tests/RefParserTests.cs +++ b/tests/ExcelReader.Tests/RefParserTests.cs @@ -208,6 +208,26 @@ public void ParseNamed_Date1904System_ShiftsParsedDateBy1462Days() Assert.Equal(1462, (date1904Result - date1900).Days); } + [Fact] + public async Task ParseNamed_AwaitForeach_EnumeratesRows() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Name", "Id", "Value", "Date"], + ["Alice", 1, 10.5, SampleDate], + ["Bob", 2, -3.25, SampleDate.AddDays(1)]); + + using var reader = Excel.From(ms, leaveOpen: true); + var results = new List<(string? Name, int Id)>(); + await foreach (SaleNamedRef s in RefParser.ParseNamed(reader)) + { + results.Add((s.Name, s.Id)); + } + + Assert.Equal(2, results.Count); + Assert.Equal(("Alice", 1), results[0]); + Assert.Equal(("Bob", 2), results[1]); + } + [Fact] public async Task IEnumerableInterop_Throws() { diff --git a/tests/ExcelReader.Tests/SyncAsyncParityTests.cs b/tests/ExcelReader.Tests/SyncAsyncParityTests.cs index 5421a482..5c337c09 100644 --- a/tests/ExcelReader.Tests/SyncAsyncParityTests.cs +++ b/tests/ExcelReader.Tests/SyncAsyncParityTests.cs @@ -16,6 +16,7 @@ public static IEnumerable Fixtures yield return [new ParityFixture("xlsx many rows (mid-stream refills)", BuildManyRowsXlsxAsync, stream => Excel.From(stream), OpenXlsxAsync)]; yield return [new ParityFixture("xls mixed rows", BuildMixedXlsAsync, stream => Excel.FromXls(stream), OpenXlsAsync)]; yield return [new ParityFixture("xlsb mixed rows", BuildMixedXlsbAsync, stream => Excel.FromXlsb(stream), OpenXlsbAsync)]; + yield return [new ParityFixture("csv mixed rows", BuildCsvAsync, stream => Excel.FromCsv(stream), OpenCsvAsync)]; } } @@ -28,8 +29,12 @@ public async Task SyncAndAsyncEnumeratorsProduceIdenticalCells(ParityFixture fix List sync = ReadSync(workbook, fixture.OpenSync); List asyncCells = await ReadAsync(workbook, fixture.OpenAsync, ct); + // The reader's GetAsyncEnumerator() (synchronous open, async row streaming — the 'await foreach' + // entry point) must produce the same cells as both the sync path and the async-open path. + List asyncEnum = await ReadViaAsyncEnumeratorAsync(workbook, fixture.OpenSync); Assert.Equal(sync, asyncCells); + Assert.Equal(sync, asyncEnum); } private static List ReadSync(byte[] workbook, Func open) @@ -63,6 +68,23 @@ private static async Task> ReadAsync( return cells; } + // Drives the reader's GetAsyncEnumerator() — a synchronous sheet open whose rows are then streamed + // via MoveNextAsync (what 'await foreach' binds to). Opens the workbook synchronously; only the + // per-row advance is awaited. + 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(); + List cells = []; + int rowIndex = 0; + while (await e.MoveNextAsync()) + { + AddRow(cells, rowIndex++, e.Current); + } + return cells; + } + private static void AddRow(List cells, int rowIndex, Row row) { cells.Add(CellSnapshot.RowMarker(rowIndex, row.ColumnCount)); @@ -192,6 +214,22 @@ private static async ValueTask BuildMixedXlsbAsync(CancellationToken ct) return ms.ToArray(); } + private static ValueTask BuildCsvAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + const string csv = + "Name,Age,Active\n" + + "Ana,31,true\n" + + "\"Bia, Jr.\",27,false\n" + // quoted field with an embedded comma + "Cid,,\n"; // trailing empty fields + return ValueTask.FromResult(System.Text.Encoding.UTF8.GetBytes(csv)); + } + + 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) { return await Excel.FromAsync(stream, ct: ct);