Skip to content

Commit 907cc5e

Browse files
Merge pull request #37 from GabrielMarquezMatte/develop
Implement async enumerators and enhance related documentation and tests
2 parents ba6b932 + 08feff8 commit 907cc5e

16 files changed

Lines changed: 301 additions & 26 deletions

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Relative ordering matches the lower-level writers above (XLS fastest, then XLSB,
103103
| `struct` (`ExcelParser<T>`) | 15.10 ms | 1.59 MB |
104104
| `ref struct` + span binding (`RefParser.ParseNamed<T>`) | 12.91 ms | 17.17 KB |
105105

106-
Parsing into a `ref struct` with a `ReadOnlySpan<byte>` 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<T>`) and is sync-only: a `ref struct` element type cannot appear in `IAsyncEnumerable<T>`, so this has no async counterpart, permanently.
106+
Parsing into a `ref struct` with a `ReadOnlySpan<byte>` 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<T>`). It can be consumed with `foreach` or `await foreach` but not through `IEnumerable<T>`/`IAsyncEnumerable<T>`/LINQ — a `ref struct` element can't be boxed through those interfaces.
107107

108108
### Cold start
109109

@@ -189,12 +189,24 @@ CSV is exposed as a single, unnamed sheet (`SheetCount == 1`, `SheetName == ""`)
189189

190190
## Read asynchronously
191191

192-
`Row` and `Cell` are `ref struct` types, so async reading uses a manual loop instead of `await foreach`.
193-
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.
192+
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.
194193

195194
```csharp
196195
using ExcelReader.Core.Reader;
197196

197+
await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken);
198+
199+
await foreach (var row in reader)
200+
{
201+
Console.WriteLine(row[0].GetString());
202+
}
203+
```
204+
205+
`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.
206+
207+
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:
208+
209+
```csharp
198210
await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken);
199211
await using var rows = await reader.GetAsyncEnumeratorAsync(cancellationToken);
200212

@@ -205,6 +217,8 @@ while (await rows.MoveNextAsync())
205217
}
206218
```
207219

220+
`await foreach` does not accept `.WithCancellation(ct)`: `Row` being a `ref struct` rules out `IAsyncEnumerable<Row>`, 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.
221+
208222
## Parse typed rows
209223

210224
`ExcelParser<T>` 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<ChangeRowRef>(reader))
330344
}
331345
```
332346

347+
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`:
348+
349+
```csharp
350+
await using var reader = await Excel.FromFileAsync("changes.xlsx");
351+
352+
await foreach (ChangeRowRef item in RefParser.ParseNamed<ChangeRowRef>(reader))
353+
{
354+
Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}");
355+
}
356+
```
357+
333358
A few differences from `ExcelParser<T>`:
334359

335-
- **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.
336-
- **Sync only, permanently.** `IAsyncEnumerable<T>` cannot have a `ref struct` element type, so there is no async counterpart — not a missing feature, a language limitation.
337-
- **`foreach` only.** The returned sequence cannot be consumed through `IEnumerable<T>`/LINQ — a `ref struct` element can't be boxed through that interface — so iterate it directly.
360+
- **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.
361+
- **`foreach` / `await foreach` only.** Consumption is pattern-based — the sequence cannot be surfaced through `IEnumerable<T>`, `IAsyncEnumerable<T>`, or LINQ, because a `ref struct` element can't be boxed through those interfaces (`IAsyncEnumerable<T>` in particular forbids a `ref struct` element type — CS9267). Iterate it directly.
338362
- **Not AOT/trim-safe**, same tradeoff as `ExcelParser<T>` (both reflect over `T`'s properties and compile setters at runtime).
339363
- A regular `struct`/`class` model works with `ParseNamed` too — only a genuine `ref struct` model gets the extra zero-copy span-property binding.
340364

src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ IAsyncEnumerator<T> IAsyncEnumerable<T>.GetAsyncEnumerator(CancellationToken can
5454
return GetAsyncEnumerator(cancellationToken);
5555
}
5656

57+
[SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
58+
Justification = "Async enumerator requires a class to host the async state machine.")]
5759
public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
5860
{
5961
TypeMapInfo<T> info = TypeMapper<T>.GetCsvInfo();

src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,33 @@ internal NamedRefRowEnumerable(
3939
_context = new ExcelRowContext(reader.IsDate1904, formatProvider ?? CultureInfo.InvariantCulture);
4040
}
4141

42-
// Zero-allocation struct enumerator — the supported way to consume this sequence.
42+
// The supported way to consume this sequence via foreach.
43+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "HLQ006:GetEnumerator should return a value type",
44+
Justification = "The enumerator is a class so the same type can also expose MoveNextAsync for the async path.")]
4345
public NamedRefRowEnumerator<TModel, TEnumerator> GetEnumerator()
4446
{
45-
return new NamedRefRowEnumerator<TModel, TEnumerator>(
46-
_reader.GetEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow);
47+
return new(_reader.GetEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow);
48+
}
49+
50+
// The 'await foreach' entry point: C#'s pattern-based async binding picks up this parameterless
51+
// GetAsyncEnumerator() (the enumerator it returns has MoveNextAsync/Current/DisposeAsync). Opens
52+
// the sheet synchronously — the reader's GetAsyncEnumerator() is a sync open (no I/O await), the
53+
// async work is per-row via MoveNextAsync. A ref-struct TModel can't be surfaced through
54+
// IAsyncEnumerable<TModel> (CS9267), so this stays a pattern match, never the interface.
55+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
56+
Justification = "The enumerator is a class so the same type can also expose MoveNextAsync for the async path.")]
57+
public NamedRefRowEnumerator<TModel, TEnumerator> GetAsyncEnumerator()
58+
{
59+
return new(_reader.GetAsyncEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow);
60+
}
61+
62+
// Manual-use alternative that opens the sheet asynchronously (awaits the reader's async open).
63+
// Not reachable by 'await foreach' — its shape (returning a ValueTask of the enumerator) doesn't
64+
// match the pattern. Await it, then drive the returned enumerator with MoveNextAsync in a loop.
65+
public async ValueTask<NamedRefRowEnumerator<TModel, TEnumerator>> GetAsyncEnumeratorAsync(CancellationToken ct = default)
66+
{
67+
var enumerator = await _reader.GetAsyncEnumeratorAsync(ct).ConfigureAwait(false);
68+
return new(enumerator, _context, _typeInfo, _comparer, _normalization, _headerRow);
4769
}
4870

4971
IEnumerator<TModel> IEnumerable<TModel>.GetEnumerator()

src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@ namespace ExcelReader.Core.Parser.Internal
1616
// call, immediately writing into a caller-supplied `ref T model`. Every existing caller stores
1717
// that result in a class FIELD (SyncRowEnumerator<T,TRows>.CurrentValue) — illegal for a
1818
// ref-struct-constrained T (CS8345: a ref struct field is only legal inside another ref struct).
19-
// So this type splits the same two responsibilities instead: MoveNext() only advances/classifies
20-
// (skip pre-header rows, build the column map once at the header row), and Current's getter parses
21-
// the row into a fresh LOCAL model on every access — never stored as a field, safe to call
22-
// repeatedly (idempotent) for the same row.
23-
public struct NamedRefRowEnumerator<TModel, TEnumerator> : IDisposable
19+
// So this type splits the same two responsibilities instead: MoveNext()/MoveNextAsync() only
20+
// advance/classify (skip pre-header rows, build the column map once at the header row), and Current's
21+
// getter parses the row into a fresh LOCAL model on every access — never stored as a field, safe to
22+
// call repeatedly (idempotent) for the same row.
23+
//
24+
// A reference type (not a struct): MoveNextAsync's awaiting slow path mutates enumeration state
25+
// (_rowNumber, and the one-time column map) across an await, which a struct would silently lose to the
26+
// async state machine's by-value `this` copy. A class shares the one instance, exactly like
27+
// AsyncRowEnumerator<T,TReader,TRows>. The lazy-Current design above is still required regardless:
28+
// a ref-struct TModel can never be stored in a field (CS8345), class or struct.
29+
public sealed class NamedRefRowEnumerator<TModel, TEnumerator> : IDisposable, IAsyncDisposable
2430
where TModel : allows ref struct
2531
where TEnumerator : class, IExcelRowEnumerator
2632
{
@@ -55,7 +61,7 @@ internal NamedRefRowEnumerator(
5561
}
5662

5763
// Recomputed on every access (see class remarks) — never cached in a field.
58-
public readonly TModel Current
64+
public TModel Current
5965
{
6066
get
6167
{
@@ -85,6 +91,63 @@ public bool MoveNext()
8591
return false;
8692
}
8793

94+
// Async twin of MoveNext, mirroring AsyncRowEnumerator<T,TReader,TRows>.MoveNextAsync: a non-async
95+
// fast path that stays synchronous whenever the underlying row-enumerator resolves synchronously
96+
// (the common case — no second state machine on top of _rows' own), only falling to an awaiting
97+
// continuation on a genuine buffer miss. Every state mutation (ClassifyRow's ref _rowNumber,
98+
// BuildColumnMap) runs on the shared class instance, so it survives the await (see class remarks).
99+
[SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task",
100+
Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
101+
[SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks",
102+
Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
103+
public ValueTask<bool> MoveNextAsync()
104+
{
105+
while (true)
106+
{
107+
ValueTask<bool> moveTask = _rows.MoveNextAsync();
108+
if (!moveTask.IsCompletedSuccessfully)
109+
{
110+
return AwaitThenContinueAsync(moveTask);
111+
}
112+
if (!moveTask.Result)
113+
{
114+
return new ValueTask<bool>(false);
115+
}
116+
switch (ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _bindings is not null))
117+
{
118+
case ProjectionStep.Yield:
119+
return new ValueTask<bool>(true);
120+
case ProjectionStep.BuildMap:
121+
BuildColumnMap(_rows.Current);
122+
break;
123+
case ProjectionStep.Stop:
124+
return new ValueTask<bool>(false);
125+
// Skip: loop again, still synchronous.
126+
}
127+
}
128+
}
129+
130+
private async ValueTask<bool> AwaitThenContinueAsync(ValueTask<bool> pendingMoveNext)
131+
{
132+
if (!await pendingMoveNext.ConfigureAwait(false))
133+
{
134+
return false;
135+
}
136+
ProjectionStep step = ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _bindings is not null);
137+
switch (step)
138+
{
139+
case ProjectionStep.Yield:
140+
return true;
141+
case ProjectionStep.BuildMap:
142+
BuildColumnMap(_rows.Current);
143+
break; // map built at the header row — resume the fast path for the next row.
144+
case ProjectionStep.Stop:
145+
return false;
146+
// Skip: resume the fast path.
147+
}
148+
return await MoveNextAsync().ConfigureAwait(false);
149+
}
150+
88151
private void BuildColumnMap(Row row)
89152
{
90153
int propertyCount = _typeInfo.PropertyCount;
@@ -143,7 +206,7 @@ private void BuildColumnMap(Row row)
143206
_seen = requireValueCount > 0 ? new bool[bindings.Length] : [];
144207
}
145208

146-
private readonly void ParseCurrentRow(Row row, ref TModel model)
209+
private void ParseCurrentRow(Row row, ref TModel model)
147210
{
148211
NamedColumnBinding<TModel>[] bindings = _bindings!;
149212
bool track = _requireValueCount > 0;
@@ -187,21 +250,31 @@ private readonly void ParseCurrentRow(Row row, ref TModel model)
187250
}
188251
}
189252

190-
private readonly void ValidateRowValues(NamedColumnBinding<TModel>[] bindings)
253+
private void ValidateRowValues(NamedColumnBinding<TModel>[] bindings)
191254
{
192255
for (int i = 0; i < bindings.Length; i++)
193256
{
194-
if (bindings[i].RequireValue && !_seen[i])
257+
ref readonly var binding = ref bindings[i];
258+
if (binding.RequireValue && !_seen[i])
195259
{
196-
throw ProjectionRules.MissingRequiredValue(bindings[i].Name, _rowNumber);
260+
throw ProjectionRules.MissingRequiredValue(binding.Name, _rowNumber);
197261
}
198262
}
199263
}
200264

201-
public readonly void Dispose()
265+
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
266+
Justification = "_rows is created for this enumerator alone by NamedRefRowEnumerable.Get(Async)Enumerator() — owned here, not injected.")]
267+
public void Dispose()
202268
{
203269
_rows.Dispose();
204270
}
271+
272+
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
273+
Justification = "_rows is created for this enumerator alone by NamedRefRowEnumerable.Get(Async)Enumerator() — owned here, not injected.")]
274+
public ValueTask DisposeAsync()
275+
{
276+
return _rows.DisposeAsync();
277+
}
205278
}
206279
}
207280
#endif

src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public bool MoveNext()
4747

4848
private protected abstract ProjectionStep Project();
4949

50+
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
51+
Justification = "Rows is created for this enumerator alone by the enclosing enumerable's GetEnumerator (reader.GetEnumerator()) — owned here, not injected.")]
5052
public void Dispose()
5153
{
5254
Rows.Dispose();

src/ExcelReader.Core/Reader/CsvReader.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ IExcelRowEnumerator IExcelRowReader<IExcelRowEnumerator>.GetEnumerator()
9797
return GetEnumerator();
9898
}
9999

100+
[SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
101+
Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for the async path.")]
102+
public Enumerator GetAsyncEnumerator()
103+
{
104+
return GetEnumerator();
105+
}
106+
107+
IExcelRowEnumerator IExcelRowReader<IExcelRowEnumerator>.GetAsyncEnumerator()
108+
{
109+
return GetAsyncEnumerator();
110+
}
111+
100112
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created",
101113
Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")]
102114
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope",

src/ExcelReader.Core/Reader/IExcelRowReader.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public interface IExcelRowReader<TEnumerator>
77
{
88
bool IsDate1904 { get; }
99
TEnumerator GetEnumerator();
10+
TEnumerator GetAsyncEnumerator();
1011
ValueTask<TEnumerator> GetAsyncEnumeratorAsync(CancellationToken ct = default);
1112
}
1213

src/ExcelReader.Core/Reader/XlsReader.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,37 @@ IExcelRowEnumerator IExcelRowReader<IExcelRowEnumerator>.GetEnumerator()
103103

104104
[SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
105105
Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for parity with XlsxReader.")]
106-
public Enumerator GetAsyncEnumerator(CancellationToken ct = default)
106+
public Enumerator GetAsyncEnumerator()
107+
{
108+
return GetEnumerator();
109+
}
110+
111+
// ct overload takes no default value: the parameterless GetAsyncEnumerator() above already covers
112+
// the no-argument call, so a default here would only shadow it.
113+
[SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
114+
Justification = "Enumerator is a class so the same type can also expose MoveNextAsync for parity with XlsxReader.")]
115+
public Enumerator GetAsyncEnumerator(CancellationToken ct)
107116
{
108117
ct.ThrowIfCancellationRequested();
109118
return new Enumerator(this, _sheets[_current].Offset, ct);
110119
}
111120

121+
IExcelRowEnumerator IExcelRowReader<IExcelRowEnumerator>.GetAsyncEnumerator()
122+
{
123+
return GetAsyncEnumerator();
124+
}
125+
126+
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope",
127+
Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")]
112128
public ValueTask<Enumerator> GetAsyncEnumeratorAsync(CancellationToken ct = default)
113129
{
114130
return new ValueTask<Enumerator>(GetAsyncEnumerator(ct));
115131
}
116132

133+
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope",
134+
Justification = "Enumerator ownership transfers to the caller, who disposes it via await using / DisposeAsync.")]
135+
[SuppressMessage("Performance", "CA1849:Call async methods when in an async method",
136+
Justification = "XlsReader is fully in-memory; opening the enumerator is synchronous, so there is no async open to await here.")]
117137
ValueTask<IExcelRowEnumerator> IExcelRowReader<IExcelRowEnumerator>.GetAsyncEnumeratorAsync(CancellationToken ct)
118138
{
119139
return new ValueTask<IExcelRowEnumerator>(GetAsyncEnumerator(ct));

0 commit comments

Comments
 (0)