From 1ac84ce500d00d192cc0f7ab19f9bc964111e510 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Tue, 21 Jul 2026 01:14:38 -0300 Subject: [PATCH] Enhance ExcelReader to Support Ref Structs - Introduced `ExcelRowContext` to provide context for row parsing. - Updated `ColumnParserFactory` to support ref structs, enabling zero-copy parsing for `ReadOnlySpan`. - Added `NamedColumnBinding` and `NamedRefRowEnumerable` to facilitate named column parsing for ref structs. - Implemented `NamedRefRowEnumerator` to handle row enumeration for ref structs, ensuring zero-allocation. - Created `RefParser` for reflection-based parsing of ref structs, mirroring existing functionality for classes. - Added benchmarks to measure performance improvements with ref structs. - Developed comprehensive tests for `RefParser`, validating attribute-driven parsing and zero-copy behavior. --- README.md | 129 +++++++--- src/ExcelReader.Core/Parser/ExcelParser.cs | 9 + .../Parser/ExcelRowContext.cs | 18 ++ .../Parser/Internal/ColumnParserFactory.cs | 63 +++++ .../Parser/Internal/Delegates.cs | 5 +- .../Parser/Internal/HeaderMatch.cs | 3 + .../Parser/Internal/NamedColumnBinding.cs | 24 ++ .../Parser/Internal/NamedRefRowEnumerable.cs | 62 +++++ .../Parser/Internal/NamedRefRowEnumerator.cs | 207 ++++++++++++++++ .../Parser/Internal/PropertyMap.cs | 3 + .../Parser/Internal/TypeMapInfo.cs | 3 + .../Parser/Internal/TypeMapper.cs | 3 + src/ExcelReader.Core/Parser/RefParser.cs | 96 ++++++++ .../ExcelReader.Benchmarks/ParseBenchmark.cs | 45 ++++ .../WorkbookGenerator.cs | 25 ++ tests/ExcelReader.Tests/RefParserTests.cs | 222 ++++++++++++++++++ 16 files changed, 885 insertions(+), 32 deletions(-) create mode 100644 src/ExcelReader.Core/Parser/ExcelRowContext.cs create mode 100644 src/ExcelReader.Core/Parser/Internal/NamedColumnBinding.cs create mode 100644 src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs create mode 100644 src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs create mode 100644 src/ExcelReader.Core/Parser/RefParser.cs create mode 100644 tests/ExcelReader.Tests/RefParserTests.cs diff --git a/README.md b/README.md index e38b00be..539ffc07 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ExcelReader is built for streaming spreadsheet workloads where low allocations m ## Benchmarks -Benchmarks were run with BenchmarkDotNet v0.15.8 on Windows 10 (22H2), AMD Ryzen 7 5700X, .NET 10.0.9 (SDK 11.0.100-preview.4). Generated-data benchmarks use 50,000 rows. +Benchmarks were run with BenchmarkDotNet v0.15.8 on Windows 10 (22H2), AMD Ryzen 7 5700X, .NET 10.0.9 (SDK 11.0.100-preview.4). Generated-data benchmarks use 50,000 rows. Raw results: [`tests/ExcelReader.Benchmarks/BenchmarkDotNet.Artifacts/results`](tests/ExcelReader.Benchmarks/BenchmarkDotNet.Artifacts/results). ### XLSX @@ -22,49 +22,49 @@ Compares ExcelReader against established XLSX libraries on the same generated wo | Scenario | ExcelReader | MiniExcel | Sylvan | SpreadCheetah | |---|---:|---:|---:|---:| -| Cell-by-cell read | 11.652 ms, 12.49 KB | 141.968 ms, 209.00 MB | 35.183 ms, 1.89 MB | - | -| Cell-by-cell read async | 12.827 ms, 14.51 KB | - | - | - | -| Typed row parsing | 13.818 ms, 3.87 MB | 151.842 ms, 196.99 MB | 56.257 ms, 10.47 MB | - | -| Typed row parsing async | 15.135 ms, 3.88 MB | - | 59.240 ms, 10.48 MB | - | -| Workbook writing | 13.910 ms, 4.02 MB | 282.372 ms, 84.89 MB | - | 15.109 ms, 15.84 MB | -| Workbook writing, shared strings | 14.127 ms, 4.06 MB | - | - | - | +| Cell-by-cell read | 11.650 ms, 16.80 KB | 149.472 ms, 209.02 MB | 35.843 ms, 1.89 MB | - | +| Cell-by-cell read async | 11.744 ms, 18.93 KB | - | - | - | +| Typed row parsing | 15.099 ms, 3.88 MB | 154.309 ms, 197.79 MB | 57.022 ms, 10.48 MB | - | +| Typed row parsing async | 14.620 ms, 3.88 MB | - | 58.538 ms, 10.51 MB | - | +| Workbook writing | 14.442 ms, 4.02 MB | 280.914 ms, 84.90 MB | - | 15.912 ms, 15.84 MB | +| Workbook writing, shared strings | 14.796 ms, 4.06 MB | - | - | - | -ExcelReader is ~12.2x faster than MiniExcel and ~3.0x faster than Sylvan for raw XLSX reads. For typed parsing, it is ~11.0x faster than MiniExcel and ~4.1x faster than Sylvan while allocating much less memory. For XLSX writing, ExcelReader is ~1.1x faster than SpreadCheetah and allocates ~3.9x less memory; it is ~20.3x faster than MiniExcel and allocates ~21x less. +ExcelReader is ~12.8x faster than MiniExcel and ~3.1x faster than Sylvan for raw XLSX reads, allocating ~12,700x and ~115x less respectively. For typed parsing, it is ~10.2x faster than MiniExcel and ~3.8x faster than Sylvan. For XLSX writing, ExcelReader is ~1.1x faster than SpreadCheetah and allocates ~3.9x less memory; it is ~19.5x faster than MiniExcel and allocates ~21x less. ### XLSB (BIFF12) | Scenario | ExcelReader | |---|---:| -| Cell-by-cell read | 4.699 ms, 14.23 KB | -| Cell-by-cell read async | 4.873 ms, 16.67 KB | -| Typed row parsing | 6.288 ms, 3.88 MB | -| Typed row parsing async | 6.726 ms, 3.88 MB | -| Workbook writing | 6.892 ms, 4.02 MB | -| Workbook writing, shared strings | 7.418 ms, 4.06 MB | +| Cell-by-cell read | 4.418 ms, 16.43 KB | +| Cell-by-cell read async | 4.781 ms, 18.98 KB | +| Typed row parsing | 6.870 ms, 3.88 MB | +| Typed row parsing async | 6.928 ms, 3.88 MB | +| Workbook writing | 7.524 ms, 4.02 MB | +| Workbook writing, shared strings | 7.382 ms, 4.06 MB | -XLSB is the fastest generated Excel format in these results: raw reads are ~2.5x faster than XLSX reads, typed parsing is ~2.2x faster than XLSX parsing, and writing is ~2.0x faster than XLSX writing. The XLSB writer is also ~2.2x faster than SpreadCheetah on this benchmark while allocating ~75% less memory. +XLSB is the fastest generated Excel format in these results: raw reads are ~2.6x faster than XLSX reads, typed parsing is ~2.2x faster than XLSX parsing, and writing is ~1.9x faster than XLSX writing. The XLSB writer is also ~2.1x faster than SpreadCheetah on this benchmark while allocating ~75% less memory. ### XLS (BIFF8) | Scenario | ExcelReader | Sylvan | |---|---:|---:| -| Cell-by-cell read | 4.602 ms, 2.89 KB | 5.328 ms, 1,717.73 KB | -| Cell-by-cell read async | 4.572 ms, 2.96 KB | - | -| Workbook writing | 5.201 ms, 16.03 MB | - | +| Cell-by-cell read | 4.267 ms, 3.69 KB | 5.510 ms, 1,717.98 KB | +| Cell-by-cell read async | 4.270 ms, 3.76 KB | - | +| Workbook writing | 5.422 ms, 16.03 MB | - | -ExcelReader is ~1.2x faster than Sylvan for generated XLS reads while allocating ~590x less memory. The XLS writer is ~2.7x faster than the XLSX writer in this benchmark, but it allocates more because the BIFF8/OLE container is assembled in memory. +ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~466x less memory. The XLS writer is ~2.7x faster than the XLSX writer in this benchmark, but it allocates more because the BIFF8/OLE container is assembled in memory. ### CSV | Scenario | ExcelReader | Sep | Sylvan.Data.Csv | CsvHelper | |---|---:|---:|---:|---:| -| Cell-by-cell read | 4.453 ms, 232 B | 7.598 ms, 3.93 KB | 4.759 ms, 1.61 MB | 24.283 ms, 15.52 MB | -| Cell-by-cell read async | 4.837 ms, 352 B | - | - | - | -| Typed row parsing | 5.824 ms, 3.86 MB | 8.280 ms, 3.87 MB | 12.749 ms, 10.95 MB | 22.606 ms, 14.41 MB | -| Typed row parsing async | 6.091 ms, 3.86 MB | - | - | - | -| Row writing | 6.683 ms, 4.00 MB | 6.898 ms, 4.01 MB | 7.163 ms, 4.04 MB | 14.377 ms, 13.79 MB | +| Cell-by-cell read | 5.228 ms, 785 B | 7.671 ms, 4.71 KB | 4.820 ms, 1.61 MB | 24.928 ms, 14.37 MB | +| Cell-by-cell read async | 4.858 ms, 905 B | - | - | - | +| Typed row parsing | 6.113 ms, 3.86 MB | 8.443 ms, 3.87 MB | 12.736 ms, 10.95 MB | 23.252 ms, 14.41 MB | +| Typed row parsing async | 6.187 ms, 3.86 MB | - | - | - | +| Row writing | 6.625 ms, 4.00 MB | 7.083 ms, 4.01 MB | 6.928 ms, 4.04 MB | 14.091 ms, 13.78 MB | -For raw CSV reads, ExcelReader is ~1.7x faster than Sep and ~1.1x faster than Sylvan.Data.Csv while allocating ~17x less than Sep and ~7,300x less than Sylvan.Data.Csv; CsvHelper is ~5.5x slower. For typed CSV parsing, ExcelReader is ~1.4x faster than Sep, ~2.2x faster than Sylvan.Data.Csv, and ~3.9x faster than CsvHelper while keeping allocations at ~3.86 MB. For CSV writing, ExcelReader is close to Sep and Sylvan.Data.Csv, and ~2.2x faster than CsvHelper; the extra allocation shown here is primarily the destination `MemoryStream` growth, not per-row writer state. +For raw CSV reads, ExcelReader is ~1.5x faster than Sep while allocating ~6x less; Sylvan.Data.Csv is marginally faster here (~9%) but allocates ~2,150x more (1.61 MB vs 785 B) — worth it only if raw wall-clock time matters more than memory pressure; CsvHelper is ~4.8x slower and allocates ~19,200x more. For typed CSV parsing (the more common case — building actual records), ExcelReader is ~1.4x faster than Sep, ~2.1x faster than Sylvan.Data.Csv, and ~3.8x faster than CsvHelper, with the lowest allocation of the group. For CSV writing, ExcelReader, Sep, and Sylvan.Data.Csv are all within ~7% of each other, and ~2.1x faster than CsvHelper; the ~4 MB shown across the first three is primarily the benchmark's pre-sized destination `MemoryStream`, not per-row writer state. ### Real data reads @@ -72,13 +72,49 @@ This benchmark reads a real workbook exported in multiple formats. | Format | ExcelReader | Sylvan | |---|---:|---:| -| XLSX | 64.090 ms, 34.18 KB | 197.046 ms, 644.23 KB | -| XLSM | 64.751 ms, 34.22 KB | 191.153 ms, 644.30 KB | -| XLSB | 23.951 ms, 40.66 KB | 29.959 ms, 338.54 KB | -| XLS | 12.381 ms, 9.97 KB | 18.205 ms, 185.90 KB | -| CSV | 5.968 ms, 232 B | 10.034 ms, 35.74 MB | +| XLSX | 66.664 ms, 67.89 KB | 201.999 ms, 648.21 KB | +| XLSM | 66.822 ms, 67.93 KB | 207.699 ms, 648.29 KB | +| XLSB | 22.936 ms, 49.09 KB | 30.097 ms, 338.91 KB | +| XLS | 11.895 ms, 13.31 KB | 18.355 ms, 189.91 KB | +| CSV | 5.952 ms, 785 B | 10.522 ms, 35.75 MB | -On this real-data workload, ExcelReader is ~3.0x faster than Sylvan for XLSX/XLSM, ~1.3x faster for XLSB, ~1.5x faster for XLS, and ~1.7x faster for CSV. Allocations stay under 41 KB for XLSX/XLSM/XLSB, ~10 KB for XLS, and 232 B for CSV. +On this real-data workload, ExcelReader is ~3.0x faster than Sylvan for XLSX, ~3.1x faster for XLSM, ~1.3x faster for XLSB, ~1.5x faster for XLS, and ~1.8x faster for CSV — allocating ~9.5x less for XLSX/XLSM, ~6.9x less for XLSB, ~14.3x less for XLS, and ~47,750x less for CSV (785 B vs 35.75 MB). + +### Typed record writing + +`WorkbookRecordWriter`/`RecordWriter` (the header-plus-one-row-per-object API — see [Write typed records](#write-typed-records)) across all four formats, same 50,000-record source: + +| Format | Mean | Allocated | +|---|---:|---:| +| XLSX | 15.410 ms | 4.02 MB | +| XLSB | 8.467 ms | 4.02 MB | +| XLS | 5.365 ms | 4.03 MB | +| CSV | 7.183 ms | 4.00 MB | + +Relative ordering matches the lower-level writers above (XLS fastest, then XLSB, then CSV, then XLSX) — the record-mapping layer adds negligible overhead over hand-written cell-by-cell writes. + +### Ref struct typed parsing (zero-copy) + +`RefParser.ParseNamed` (see [Parse into a ref struct](#parse-into-a-ref-struct-zero-copy)) extends `ExcelParser`'s reflection/attribute-driven column mapping to `ref struct` targets, binding a `ReadOnlySpan` property directly to the cell's raw bytes instead of allocating a `string`. Same generated XLSX workbook, same 50,000 rows, same four columns — only the target type and binding strategy change: + +| Target | Mean | Allocated | +|---|---:|---:| +| `class` (`ExcelParser`) | 15.19 ms | 3.88 MB | +| `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. + +### Cold start + +First use of `ExcelParser`/`RecordWriter` in a process pays a one-time reflection + `Expression.Compile` cost (16 launches, cold JIT, 200 rows): + +| Scenario | Mean | Allocated | +|---|---:|---:| +| First typed parse | 39.46 ms | 75.17 KB | +| First typed record write | 21.11 ms | 89.5 KB | + +This cost is paid once per type per process and cached thereafter — irrelevant for long-running services, worth knowing for CLI tools or serverless cold starts. Run the benchmarks locally: @@ -271,6 +307,37 @@ public sealed class Invoice Return `false` to signal a parse failure (the property keeps its default). Empty cells are skipped before the converter runs. +## Parse into a ref struct (zero-copy) + +`RefParser.ParseNamed` (.NET 9+) targets a `ref struct` model instead of a class/struct — same attribute-driven column matching as `ExcelParser` (`[ExcelColumn]`, `[ExcelRequired]`, `[ExcelConverter]`), but a `ReadOnlySpan` property binds directly to the cell's raw bytes instead of allocating a `string`: + +```csharp +using System.Text; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; + +public readonly ref struct ChangeRowRef +{ + public ReadOnlySpan File { get; init; } // zero-copy — aliases the reader's row buffer + public int LinesAdded { get; init; } +} + +using var reader = Excel.FromFile("changes.xlsx"); + +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. +- **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. + ## Write XLSX workbooks ```csharp diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index b926b48a..c6083581 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -8,6 +8,15 @@ namespace ExcelReader.Core.Parser // Expression.Compile plus MakeGenericMethod, so it needs runtime code generation and keeps T's // members. Not compatible with Native AOT, and trimming can remove the properties it binds to. The // raw Excel.From* readers use no reflection and stay AOT/trim-safe; only this typed layer does not. + // + // Lower-allocation parsing: column binding runs through `ref TModel` end to end (ColumnParser, + // RefAction — see Internal/Delegates.cs), and Row/RowCell are ref structs. So a + // `struct T` consumed via a direct `foreach` (not LINQ over IEnumerable or anything else + // that boxes) skips the per-row model allocation a class T requires — measured -59% (3.88 MB -> + // 1.59 MB / 50k rows) on a 4-column benchmark record. The rest of that allocation is T's own + // reference-typed fields (e.g. a string column decodes to a fresh managed string per row + // regardless of T's kind) — struct T doesn't remove that, only the container. See + // ParseBenchmark.ExcelParserStructSync/RecordStruct in tests/ExcelReader.Benchmarks. [RequiresUnreferencedCode("Typed parsing reflects over T's public properties, which trimming may remove.")] [RequiresDynamicCode("Typed parsing compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] public sealed class ExcelParser diff --git a/src/ExcelReader.Core/Parser/ExcelRowContext.cs b/src/ExcelReader.Core/Parser/ExcelRowContext.cs new file mode 100644 index 00000000..fcd36182 --- /dev/null +++ b/src/ExcelReader.Core/Parser/ExcelRowContext.cs @@ -0,0 +1,18 @@ +#if NET9_0_OR_GREATER +namespace ExcelReader.Core.Parser +{ + // Immutable per-enumeration context handed to every IExcelRowModel.FromRow call. A normal + // readonly struct (not ref struct), so it can be passed `in` freely (unlike Row itself). + public readonly struct ExcelRowContext + { + public bool IsDate1904 { get; } + public IFormatProvider FormatProvider { get; } + + internal ExcelRowContext(bool isDate1904, IFormatProvider formatProvider) + { + IsDate1904 = isDate1904; + FormatProvider = formatProvider; + } + } +} +#endif diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 1643b3cb..36d19677 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -62,6 +62,9 @@ internal static class ColumnParserFactory // rather than an Excel serial number. Only the CSV parser opts in, because CSV has no serial // date form. Every other reader leaves csvTextDates false and keeps the serial semantics. internal static ColumnParser? Build(PropertyInfo prop, bool csvTextDates = false) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { Type propType = prop.PropertyType; Type? innerNullable = Nullable.GetUnderlyingType(propType); @@ -76,6 +79,9 @@ internal static class ColumnParserFactory // implement the interface for the property's exact type and have a public parameterless ctor // a single shared instance is created here and reused for every row. internal static ColumnParser BuildConverter(PropertyInfo prop, Type converterType) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { Type propType = prop.PropertyType; Type ifaceType = typeof(IExcelCellConverter<>).MakeGenericType(propType); @@ -92,11 +98,20 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver } private static ColumnParser? BuildConcreteParser(PropertyInfo prop, Type propType, bool textDates) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { if (propType == typeof(string)) { return BuildStringParser(prop); } +#if NET9_0_OR_GREATER + if (propType == typeof(ReadOnlySpan)) + { + return BuildSpanParser(prop); + } +#endif if (propType == typeof(bool)) { return BuildValue(prop, ReadBool); @@ -135,6 +150,9 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver } private static ColumnParser? BuildNullableParser(PropertyInfo prop, Type innerType, bool textDates) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { if (innerType == typeof(bool)) { @@ -174,6 +192,9 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver } private static ColumnParser BuildStringParser(PropertyInfo prop) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, _, _) => @@ -183,6 +204,23 @@ private static ColumnParser BuildStringParser(PropertyInfo prop) }; } +#if NET9_0_OR_GREATER + // Zero-copy text binding: aliases Cell.Value (the reader's row/shared-string buffer) directly + // instead of allocating via GetString(). Valid only until the enumerator's next MoveNext() — + // the same forward-only-cursor contract Row/Cell themselves already have; a caller that needs + // the text past that must copy it out (e.g. Encoding.UTF8.GetString(span)) within the loop body. + private static ColumnParser BuildSpanParser(PropertyInfo prop) + where T : allows ref struct + { + RefAction> setter = CompileSetter>(prop); + return (ref model, in cell, _, _) => + { + setter(ref model, cell.Value); + return true; + }; + } +#endif + // Shared shape behind every value-type column parser below: read the cell into a V via one of // the Read*/TryParse* strategies, then assign through the compiled setter. Build*Parser methods // differ only in which reader they plug in, so they collapse to one-line factories over these @@ -190,6 +228,9 @@ private static ColumnParser BuildStringParser(PropertyInfo prop) private delegate bool CellReader(in Cell cell, bool isDate1904, IFormatProvider provider, out V value); private static ColumnParser BuildValue(PropertyInfo prop, CellReader read) +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, isDate1904, provider) => @@ -205,6 +246,9 @@ private static ColumnParser BuildValue(PropertyInfo prop, CellReader private static ColumnParser BuildNullableValue(PropertyInfo prop, CellReader read) where V : struct +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, isDate1904, provider) => @@ -348,6 +392,9 @@ private static bool TryParseTimeOnlyText(in Cell cell, IFormatProvider provider, private static ColumnParser BuildParsableCore(PropertyInfo prop) where TProp : IUtf8SpanParsable +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, _, provider) => @@ -365,6 +412,9 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) Justification = "Called via MakeGenericMethod dispatch; private access is intentional and type-safe.")] private static ColumnParser BuildNullableParsableCore(PropertyInfo prop) where TProp : struct, IUtf8SpanParsable +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, _, provider) => @@ -566,6 +616,9 @@ public static bool TryParse(in Cell cell, out TEnum value) private static ColumnParser BuildEnumCore(PropertyInfo prop) where TEnum : struct, Enum +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, _, _) => @@ -581,6 +634,9 @@ private static ColumnParser BuildEnumCore(PropertyInfo prop) private static ColumnParser BuildNullableEnumCore(PropertyInfo prop) where TEnum : struct, Enum +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { RefAction setter = CompileSetter(prop); return (ref model, in cell, _, _) => @@ -604,6 +660,9 @@ private static ColumnParser BuildNullableEnumCore(PropertyInfo prop private static ColumnParser BuildConverterCore(PropertyInfo prop, object converter) where TConv : IExcelCellConverter +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { var typed = (TConv)converter; RefAction setter = CompileSetter(prop); @@ -619,6 +678,10 @@ private static ColumnParser BuildConverterCore(PropertyInfo } private static RefAction CompileSetter(PropertyInfo prop) +#if NET9_0_OR_GREATER + where T : allows ref struct + where TProp : allows ref struct +#endif { ParameterExpression modelParam = Expression.Parameter(typeof(T).MakeByRefType(), "model"); ParameterExpression valueParam = Expression.Parameter(typeof(TProp), "value"); diff --git a/src/ExcelReader.Core/Parser/Internal/Delegates.cs b/src/ExcelReader.Core/Parser/Internal/Delegates.cs index 78d64f01..281cda20 100644 --- a/src/ExcelReader.Core/Parser/Internal/Delegates.cs +++ b/src/ExcelReader.Core/Parser/Internal/Delegates.cs @@ -4,9 +4,12 @@ namespace ExcelReader.Core.Parser.Internal { // Setter compiled once per property via Expression tree. // ref TModel allows in-place mutation for both classes and structs. + // TProperty also allows ref struct (net9+) so a ReadOnlySpan property can bind directly to + // Cell.Value (zero-copy) via ColumnParserFactory's span parser — see BuildSpanParser. internal delegate void RefAction(ref TModel model, TProperty value) #if NET9_0_OR_GREATER - where TModel : allows ref struct; + where TModel : allows ref struct + where TProperty : allows ref struct; #else ; #endif diff --git a/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs b/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs index 5320d39b..05aac6e3 100644 --- a/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs +++ b/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs @@ -1,6 +1,9 @@ namespace ExcelReader.Core.Parser.Internal { internal readonly struct HeaderMatch +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { internal HeaderMatch(int propertyIndex, int aliasIndex, ColumnParser parser) { diff --git a/src/ExcelReader.Core/Parser/Internal/NamedColumnBinding.cs b/src/ExcelReader.Core/Parser/Internal/NamedColumnBinding.cs new file mode 100644 index 00000000..0a9efdc5 --- /dev/null +++ b/src/ExcelReader.Core/Parser/Internal/NamedColumnBinding.cs @@ -0,0 +1,24 @@ +#if NET9_0_OR_GREATER +namespace ExcelReader.Core.Parser.Internal +{ + // One resolved header->column->parser binding for NamedRefRowEnumerator. Mirrors RowProjector's + // private ColumnBinding, kept as its own top-level type since RowProjector can't be + // reused directly for a ref-struct TModel (see NamedRefRowEnumerator's remarks). + internal readonly struct NamedColumnBinding + where T : allows ref struct + { + internal NamedColumnBinding(int column, ColumnParser parser, bool requireValue, string name) + { + Column = column; + Parser = parser; + RequireValue = requireValue; + Name = name; + } + + internal int Column { get; } + internal ColumnParser Parser { get; } + internal bool RequireValue { get; } + internal string Name { get; } + } +} +#endif diff --git a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs new file mode 100644 index 00000000..98377dd9 --- /dev/null +++ b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerable.cs @@ -0,0 +1,62 @@ +#if NET9_0_OR_GREATER +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Core.Parser.Internal +{ + // Zero-model-allocation, attribute-driven row sequence (see NamedRefRowEnumerator). Mirrors + // RefRowEnumerable's shape exactly (struct GetEnumerator for a + // zero-allocation foreach; IEnumerable implemented only for the familiar shape — its + // members throw, since a ref struct TModel cannot be surfaced through the boxed + // IEnumerator/IEnumerator). + public sealed class NamedRefRowEnumerable : IEnumerable + where TModel : allows ref struct + where TReader : IExcelRowReader + where TEnumerator : class, IExcelRowEnumerator + { + private readonly TReader _reader; + private readonly ExcelRowContext _context; + private readonly TypeMapInfo _typeInfo; + private readonly StringComparer _comparer; + private readonly HeaderNormalization _normalization; + private readonly int _headerRow; + + internal NamedRefRowEnumerable( + TReader reader, + TypeMapInfo typeInfo, + StringComparer comparer, + HeaderNormalization normalization, + int headerRow, + IFormatProvider? formatProvider) + { + _reader = reader; + _typeInfo = typeInfo; + _comparer = comparer; + _normalization = normalization; + _headerRow = headerRow; + _context = new ExcelRowContext(reader.IsDate1904, formatProvider ?? CultureInfo.InvariantCulture); + } + + // Zero-allocation struct enumerator — the supported way to consume this sequence. + public NamedRefRowEnumerator GetEnumerator() + { + return new NamedRefRowEnumerator( + _reader.GetEnumerator(), _context, _typeInfo, _comparer, _normalization, _headerRow); + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotSupportedException( + "A ref struct row model cannot be enumerated through IEnumerable. Use a direct 'foreach' over this type instead, which binds to its struct GetEnumerator()."); + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotSupportedException( + "A ref struct row model cannot be enumerated through IEnumerable. Use a direct 'foreach' over this type instead."); + } + } +} +#endif diff --git a/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs new file mode 100644 index 00000000..9fcaae07 --- /dev/null +++ b/src/ExcelReader.Core/Parser/Internal/NamedRefRowEnumerator.cs @@ -0,0 +1,207 @@ +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using ExcelReader.Core.Enums; +using ExcelReader.Core.Reader; +using ExcelReader.Core.ValueObjects; + +namespace ExcelReader.Core.Parser.Internal +{ + // Reflection-based, attribute-driven ("[ExcelColumn]"/"[ExcelRequired]"/"[ExcelConverter]") ref + // struct row projector. Reuses TypeMapper/TypeMapInfo/ColumnParserFactory (widened + // to `allows ref struct` for this TFM) for reflection + Expression-tree byref setter compilation — + // the exact same machinery ExcelParser already uses for classes/structs, now extended to ref + // structs too. NOT AOT/trim-safe (unlike IExcelRowModel's manual FromRow path). + // + // Can't reuse RowProjector directly: RowProjector.Advance classifies AND parses a row in one + // 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 + where TModel : allows ref struct + where TEnumerator : class, IExcelRowEnumerator + { + [SuppressMessage("Performance", "HLQ011:ReadOnlyEnumeratorField", + Justification = "TEnumerator is constrained to `class` here, so it is always a reference type — no copy-on-mutate risk from a readonly field.")] + private readonly TEnumerator _rows; + private readonly ExcelRowContext _context; + private readonly TypeMapInfo _typeInfo; + private readonly StringComparer _comparer; + private readonly HeaderNormalization _normalization; + private readonly int _headerRow; + private NamedColumnBinding[]? _bindings; + private bool[] _seen; + private int _requireValueCount; + private int _rowNumber; + + internal NamedRefRowEnumerator( + TEnumerator rows, + ExcelRowContext context, + TypeMapInfo typeInfo, + StringComparer comparer, + HeaderNormalization normalization, + int headerRow) + { + _rows = rows; + _context = context; + _typeInfo = typeInfo; + _comparer = comparer; + _normalization = normalization; + _headerRow = headerRow; + _seen = []; + } + + // Recomputed on every access (see class remarks) — never cached in a field. + public readonly TModel Current + { + get + { + TModel model = default!; + ParseCurrentRow(_rows.Current, ref model); + return model; + } + } + + public bool MoveNext() + { + while (_rows.MoveNext()) + { + 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; + case ProjectionStep.Stop: + return false; + // Skip: loop again. + } + } + return false; + } + + private void BuildColumnMap(Row row) + { + int propertyCount = _typeInfo.PropertyCount; + int[] columns = new int[propertyCount]; + int[] aliasIndexes = new int[propertyCount]; + var parsers = new ColumnParser?[propertyCount]; + Array.Fill(aliasIndexes, int.MaxValue); + + int bindingCount = 0; + foreach (RowCell rowCell in row.Cells) + { + Cell cell = rowCell.Value; + string header = _normalization.Apply(cell.GetString()); + if (string.IsNullOrEmpty(header)) + { + continue; + } + if (!_typeInfo.TryFindHeader(header, _comparer, _normalization, out HeaderMatch match)) + { + continue; + } + if (match.AliasIndex >= aliasIndexes[match.PropertyIndex]) + { + continue; + } + if (aliasIndexes[match.PropertyIndex] == int.MaxValue) + { + bindingCount++; + } + columns[match.PropertyIndex] = rowCell.ColumnIndex; + parsers[match.PropertyIndex] = match.Parser; + aliasIndexes[match.PropertyIndex] = match.AliasIndex; + } + + _typeInfo.ValidateRequiredColumns(aliasIndexes); + + var bindings = new NamedColumnBinding[bindingCount]; + int index = 0; + int requireValueCount = 0; + for (int i = 0; i < parsers.Length; i++) + { + ColumnParser? parser = parsers[i]; + if (parser is not null) + { + bool requireValue = _typeInfo.RequiresValue(i); + if (requireValue) + { + requireValueCount++; + } + bindings[index++] = new NamedColumnBinding(columns[i], parser, requireValue, _typeInfo.DisplayName(i)); + } + } + Array.Sort(bindings, static (left, right) => left.Column.CompareTo(right.Column)); + _bindings = bindings; + _requireValueCount = requireValueCount; + _seen = requireValueCount > 0 ? new bool[bindings.Length] : []; + } + + private readonly void ParseCurrentRow(Row row, ref TModel model) + { + NamedColumnBinding[] bindings = _bindings!; + bool track = _requireValueCount > 0; + if (track) + { + Array.Clear(_seen, 0, bindings.Length); + } + int bindingIndex = 0; + foreach (RowCell rowCell in row.Cells) + { + int column = rowCell.ColumnIndex; + while (bindingIndex < bindings.Length && bindings[bindingIndex].Column < column) + { + bindingIndex++; + } + if (bindingIndex == bindings.Length) + { + break; + } + ref readonly NamedColumnBinding binding = ref bindings[bindingIndex]; + if (binding.Column != column) + { + continue; + } + Cell cell = rowCell.Value; + if (cell.Type == CellType.Empty) + { + bindingIndex++; + continue; + } + binding.Parser(ref model, in cell, _context.IsDate1904, _context.FormatProvider); + if (track && binding.RequireValue) + { + _seen[bindingIndex] = true; + } + bindingIndex++; + } + if (track) + { + ValidateRowValues(bindings); + } + } + + private readonly void ValidateRowValues(NamedColumnBinding[] bindings) + { + for (int i = 0; i < bindings.Length; i++) + { + if (bindings[i].RequireValue && !_seen[i]) + { + throw ProjectionRules.MissingRequiredValue(bindings[i].Name, _rowNumber); + } + } + } + + public readonly void Dispose() + { + _rows.Dispose(); + } + } +} +#endif diff --git a/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs b/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs index a42055a0..c351349c 100644 --- a/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs +++ b/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs @@ -1,6 +1,9 @@ namespace ExcelReader.Core.Parser.Internal { internal readonly struct PropertyMap +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { internal PropertyMap(string[] names, ColumnParser parser, bool isRequired, bool requireValue) { diff --git a/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs b/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs index 8dd835cb..b8438efb 100644 --- a/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs +++ b/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs @@ -3,6 +3,9 @@ namespace ExcelReader.Core.Parser.Internal { internal readonly struct TypeMapInfo +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { private readonly PropertyMap[] _properties; private readonly Func _factory; diff --git a/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs b/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs index 08f68f92..fb0e74dc 100644 --- a/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs +++ b/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs @@ -4,6 +4,9 @@ namespace ExcelReader.Core.Parser.Internal { internal static class TypeMapper +#if NET9_0_OR_GREATER + where T : allows ref struct +#endif { // ExecutionAndPublication already caches a thrown build exception and re-throws it (original // stack trace preserved) on every subsequent .Value access — no need to do that by hand. diff --git a/src/ExcelReader.Core/Parser/RefParser.cs b/src/ExcelReader.Core/Parser/RefParser.cs new file mode 100644 index 00000000..44d6a562 --- /dev/null +++ b/src/ExcelReader.Core/Parser/RefParser.cs @@ -0,0 +1,96 @@ +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using ExcelReader.Core.Parser.Internal; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Core.Parser +{ + // Reflection-based, attribute-driven typed parsing for models that may be a `ref struct` — the + // one shape ExcelParser cannot target (a ref struct can't be a type argument to + // IEnumerable/IAsyncEnumerable before this TFM). Properties are matched to header columns by + // name via [ExcelColumn]/[ExcelRequired]/[ExcelConverter], exactly like ExcelParser — same + // TypeMapper/ColumnParserFactory machinery underneath, widened to `allows ref struct` for this + // TFM. Not AOT/trim-safe, for the same reason ExcelParser isn't (reflection + Expression.Compile + // at type-map-build time, cached per closed TModel thereafter). + // + // Sync-only, permanently: IAsyncEnumerable cannot have a ref struct element type (CS9267), + // so there is no ParseAsync here, ever — not merely unimplemented. Callers who need async keep + // ExcelParser with a plain struct/class model. + // + // Text columns: a `ReadOnlySpan` property binds directly to Cell.Value — zero-copy, aliases + // the reader's row/shared-string buffer, valid only until the next MoveNext() (copy it out, e.g. + // via Encoding.UTF8.GetString(span), to retain it past the loop body). A `string` property still + // works too but allocates per row (same as ExcelParser). Any other unsupported property type is + // silently left unbound (matching ExcelParser's existing behavior) unless marked [ExcelRequired], + // which throws at type-map-build time instead. A model using only ReadOnlySpan/numeric/bool/ + // date columns is fully zero-alloc end to end — not just the container (see docs/performance-plan.md + // P2, which measured the container-only win for a plain struct; this closes the remaining gap for + // genuine ref structs too). + public static class RefParser + { + [RequiresUnreferencedCode("ParseNamed reflects over TModel's public properties, which trimming may remove.")] + [RequiresDynamicCode("ParseNamed compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] + public static NamedRefRowEnumerable ParseNamed( + XlsxReader reader, ExcelParserConfig? config = null) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(reader); + config ??= new ExcelParserConfig(); + return new NamedRefRowEnumerable( + reader, TypeMapper.GetInfo(), config.ColumnNameComparer, config.HeaderNormalization, config.HeaderRow, config.Culture); + } + + [RequiresUnreferencedCode("ParseNamed reflects over TModel's public properties, which trimming may remove.")] + [RequiresDynamicCode("ParseNamed compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] + public static NamedRefRowEnumerable ParseNamed( + XlsbReader reader, ExcelParserConfig? config = null) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(reader); + config ??= new ExcelParserConfig(); + return new NamedRefRowEnumerable( + reader, TypeMapper.GetInfo(), config.ColumnNameComparer, config.HeaderNormalization, config.HeaderRow, config.Culture); + } + + [RequiresUnreferencedCode("ParseNamed reflects over TModel's public properties, which trimming may remove.")] + [RequiresDynamicCode("ParseNamed compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] + public static NamedRefRowEnumerable ParseNamed( + XlsReader reader, ExcelParserConfig? config = null) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(reader); + config ??= new ExcelParserConfig(); + return new NamedRefRowEnumerable( + reader, TypeMapper.GetInfo(), config.ColumnNameComparer, config.HeaderNormalization, config.HeaderRow, config.Culture); + } + + // CSV uses TypeMapper.GetCsvInfo() — the only difference is DateTime/DateOnly/TimeOnly + // columns parse the cell text rather than an Excel serial number (see TypeMapper.GetCsvInfo). + [RequiresUnreferencedCode("ParseNamed reflects over TModel's public properties, which trimming may remove.")] + [RequiresDynamicCode("ParseNamed compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] + public static NamedRefRowEnumerable ParseNamed( + CsvReader reader, ExcelParserConfig? config = null) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(reader); + config ??= new ExcelParserConfig(); + return new NamedRefRowEnumerable( + reader, TypeMapper.GetCsvInfo(), config.ColumnNameComparer, config.HeaderNormalization, config.HeaderRow, config.Culture); + } + + // Format-agnostic entry point for the reader returned by Excel.Open, so callers need not + // pattern-match the concrete reader type. Dispatches through the interface enumerator. + [RequiresUnreferencedCode("ParseNamed reflects over TModel's public properties, which trimming may remove.")] + [RequiresDynamicCode("ParseNamed compiles property setters at runtime (Expression.Compile / MakeGenericMethod).")] + public static NamedRefRowEnumerable ParseNamed( + IExcelRowReader reader, ExcelParserConfig? config = null) + where TModel : allows ref struct + { + ArgumentNullException.ThrowIfNull(reader); + config ??= new ExcelParserConfig(); + return new NamedRefRowEnumerable( + reader, TypeMapper.GetInfo(), config.ColumnNameComparer, config.HeaderNormalization, config.HeaderRow, config.Culture); + } + } +} +#endif diff --git a/tests/ExcelReader.Benchmarks/ParseBenchmark.cs b/tests/ExcelReader.Benchmarks/ParseBenchmark.cs index 74992fa5..ecd8dc02 100644 --- a/tests/ExcelReader.Benchmarks/ParseBenchmark.cs +++ b/tests/ExcelReader.Benchmarks/ParseBenchmark.cs @@ -30,6 +30,16 @@ private static long Accumulate(Record rec) return rec.Id + (long)rec.Value + (rec.Name?.Length ?? 0) + rec.Date.Ticks; } + private static long Accumulate(RecordStruct rec) + { + return rec.Id + (long)rec.Value + (rec.Name?.Length ?? 0) + rec.Date.Ticks; + } + + private static long Accumulate(RecordNamedRef rec) + { + return rec.Id + (long)rec.Value + rec.Name.Length + rec.Date.Ticks; + } + [Benchmark(Baseline = true)] public long ExcelParserSync() @@ -44,6 +54,41 @@ public long ExcelParserSync() return acc; } + // Same workbook/columns as ExcelParserSync, but the target is a struct — proves out the + // zero-per-row-allocation path: ExcelParser binds columns via `ref TModel` + // (ColumnParser/RefAction, see Delegates.cs) all the way down, and Row/RowCell + // are ref structs, so a struct T and a direct foreach never box or allocate a model per row. + [Benchmark] + public long ExcelParserStructSync() + { + using var ms = new MemoryStream(_workbook, writable: false); + using var reader = Excel.From(ms); + long acc = 0; + foreach (RecordStruct rec in new ExcelParser().Parse(reader)) + { + acc += Accumulate(rec); + } + return acc; + } + + // Reflection/attribute-driven ref-struct parse (ExcelReader.Core.Parser.RefParser.ParseNamed) — + // same workbook/columns, matched by header name instead of ExcelParser's reflection-built + // property setters. Name binds via ColumnParserFactory's span parser (zero-copy Cell.Value), + // not GetString(), so this measures the fully-zero-alloc path, not just the container saving + // ExcelParserStructSync/RecordStruct already showed. + [Benchmark] + public long RefParserParseNamedSync() + { + using var ms = new MemoryStream(_workbook, writable: false); + using var reader = Excel.From(ms); + long acc = 0; + foreach (RecordNamedRef rec in RefParser.ParseNamed(reader)) + { + acc += Accumulate(rec); + } + return acc; + } + [Benchmark] public async Task ExcelParserAsync() { diff --git a/tests/ExcelReader.Benchmarks/WorkbookGenerator.cs b/tests/ExcelReader.Benchmarks/WorkbookGenerator.cs index 9fc9e278..e5536516 100644 --- a/tests/ExcelReader.Benchmarks/WorkbookGenerator.cs +++ b/tests/ExcelReader.Benchmarks/WorkbookGenerator.cs @@ -12,6 +12,31 @@ public sealed class Record public double Value { get; set; } } + // Struct twin of Record, same property names/types. ExcelParser binds properties via `ref + // TModel` throughout (ColumnParser/RefAction — see Delegates.cs), so parsing into + // a struct T avoids the per-row model allocation that a class T requires. Used by + // ParseBenchmark.ExcelParserStructSync to measure that directly. + public struct RecordStruct + { + public string? Name { get; set; } + public int Id { get; set; } + public DateTime Date { get; set; } + public double Value { get; set; } + } + + // ref struct twin of Record, parsed via RefParser.ParseNamed (reflection/attribute-driven — + // ExcelReader.Core.Parser.RefParser) rather than IExcelRowModel.FromRow. A genuine `ref struct` + // (not just a normal struct, which ExcelParser already supports) — proves ParseNamed's + // reflection pipeline works for ref structs too. Name stays `string?` (allocates per row): see + // RefParser.ParseNamed's doc comment — span-typed property binding isn't implemented yet. + public readonly ref struct RecordNamedRef + { + public ReadOnlySpan Name { get; init; } + public int Id { get; init; } + public DateTime Date { get; init; } + public double Value { get; init; } + } + // Builds self-contained workbooks in memory via the project writers. internal static class WorkbookGenerator { diff --git a/tests/ExcelReader.Tests/RefParserTests.cs b/tests/ExcelReader.Tests/RefParserTests.cs new file mode 100644 index 00000000..21abf168 --- /dev/null +++ b/tests/ExcelReader.Tests/RefParserTests.cs @@ -0,0 +1,222 @@ +#if NET9_0_OR_GREATER +using System.Collections; +using System.Collections.Generic; +using System.Text; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.ValueObjects; + +namespace ExcelReader.Tests +{ + public class RefParserTests + { + private sealed class SaleClass + { + public string? Name { get; set; } + public int Id { get; set; } + public double Value { get; set; } + public DateTime Date { get; set; } + } + + // ParseNamed target: reflection/attribute-driven, matched by header name (not column index). + // Deliberately declares Value before Id (order-independent — proves this isn't positional) and + // uses [ExcelRequired] to prove the existing ExcelParser attribute pipeline threads through. + // A genuine `ref struct` — proves ParseNamed's reflection pipeline works for ref structs too, + // not just normal structs (which ExcelParser already supports). + private readonly ref struct SaleNamedRef + { + public string? Name { get; init; } + [ExcelRequired] + public int Id { get; init; } + public double Value { get; init; } + public DateTime Date { get; init; } + } + + // Proves ColumnParserFactory.BuildSpanParser: a ReadOnlySpan property binds directly to + // Cell.Value (zero-copy) instead of falling back to string/GetString(). + private readonly ref struct SaleSpanRef + { + public ReadOnlySpan Name { get; init; } + public int Id { get; init; } + } + + private sealed class UpperCaseConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out string value) + { + value = cell.GetString().ToUpperInvariant(); + return true; + } + } + + // Exercises the three attributes not covered by SaleNamedRef/SaleSpanRef: [ExcelColumn] (header + // alias), [ExcelIgnore] (never bound, even if a matching header exists), and [ExcelConverter] + // (custom IExcelCellConverter — TProp is the PROPERTY type, unrelated to the model being + // a ref struct, so this reuses BuildConverterCore unchanged for T=ref struct). + private readonly ref struct AttributeRef + { + [ExcelColumn("First Name")] + public string? FirstName { get; init; } + + [ExcelIgnore] + public int Ignored { get; init; } + + [ExcelConverter(typeof(UpperCaseConverter))] + public string? Shout { get; init; } + } + + private static readonly DateTime SampleDate = DateTime.FromOADate(45292.25); + + [Fact] + public async Task ParseNamed_SupportsColumnAliasIgnoreAndConverterAttributes() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["First Name", "Ignored", "Shout"], + ["Alice", 999, "hello"]); + + using var reader = Excel.From(ms, leaveOpen: true); + var enumerator = RefParser.ParseNamed(reader).GetEnumerator(); + Assert.True(enumerator.MoveNext()); + AttributeRef a = enumerator.Current; + Assert.Equal("Alice", a.FirstName); // [ExcelColumn] alias — header is "First Name", not "FirstName" + Assert.Equal(0, a.Ignored); // [ExcelIgnore] — never bound despite a matching "Ignored" header + Assert.Equal("HELLO", a.Shout); // [ExcelConverter] — UpperCaseConverter ran + } + + [Fact] + public async Task ParseNamed_SpanProperty_BindsDirectlyToCellValue() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Name", "Id"], + ["Alice", 1]); + + using var reader = Excel.From(ms, leaveOpen: true); + var enumerator = RefParser.ParseNamed(reader).GetEnumerator(); + Assert.True(enumerator.MoveNext()); + SaleSpanRef s = enumerator.Current; + Assert.Equal("Alice", Encoding.UTF8.GetString(s.Name)); + Assert.Equal(1, s.Id); + } + + [Fact] + public async Task ParseNamed_MatchesClassBasedParser() + { + 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 namedReader = Excel.From(ms, leaveOpen: true); + var namedResults = new List<(string? Name, int Id, double Value, DateTime Date)>(); + foreach (SaleNamedRef s in RefParser.ParseNamed(namedReader)) + { + namedResults.Add((s.Name, s.Id, s.Value, s.Date)); + } + + ms.Position = 0; + using var classReader = Excel.From(ms, leaveOpen: true); + List classResults = new ExcelParser().Parse(classReader).ToList(); + + Assert.Equal(2, namedResults.Count); + Assert.Equal(classResults.Count, namedResults.Count); + for (int i = 0; i < namedResults.Count; i++) + { + Assert.Equal(classResults[i].Name, namedResults[i].Name); + Assert.Equal(classResults[i].Id, namedResults[i].Id); + Assert.Equal(classResults[i].Value, namedResults[i].Value); + Assert.Equal(classResults[i].Date, namedResults[i].Date); + } + } + + [Fact] + public async Task ParseNamed_IsHeaderOrderIndependent() + { + // Columns shuffled relative to SaleNamedRef's declaration order — proves binding is by + // header NAME, not by column position. + await using var ms = await TypedWorkbook.BuildAsync( + ["Date", "Value", "Name", "Id"], + [SampleDate, 10.5, "Alice", 1]); + + using var reader = Excel.From(ms, leaveOpen: true); + var enumerator = RefParser.ParseNamed(reader).GetEnumerator(); + Assert.True(enumerator.MoveNext()); + SaleNamedRef s = enumerator.Current; + Assert.Equal("Alice", s.Name); + Assert.Equal(1, s.Id); + Assert.Equal(10.5, s.Value); + Assert.Equal(SampleDate, s.Date); + } + + [Fact] + public async Task ParseNamed_MissingRequiredColumn_Throws() + { + // No "Id" column — SaleNamedRef.Id is [ExcelRequired], matching ExcelParser's existing + // TypeMapper.ValidateRequiredColumns behavior (reused unchanged for the ref-struct path). + await using var ms = await TypedWorkbook.BuildAsync( + ["Name", "Value", "Date"], + ["Alice", 10.5, SampleDate]); + + using var reader = Excel.From(ms, leaveOpen: true); + var enumerable = RefParser.ParseNamed(reader); + Assert.Throws(() => + { + var enumerator = enumerable.GetEnumerator(); + enumerator.MoveNext(); + }); + } + + [Fact] + public async Task ParseNamed_EmptyCell_YieldsDefaultForThatColumn() + { + // Value is NOT [ExcelRequired] — an empty cell there should yield 0.0, not throw (unlike + // Id, which is required and would throw on an empty cell — see ParseNamed_MissingRequiredColumn_Throws + // for the presence check and ExcelRequiredAttribute's own doc for the non-empty check). + await using var ms = await TypedWorkbook.BuildAsync( + ["Name", "Id", "Value", "Date"], + ["Alice", 1, new Gap(), SampleDate]); + + using var reader = Excel.From(ms, leaveOpen: true); + var enumerator = RefParser.ParseNamed(reader).GetEnumerator(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(0.0, enumerator.Current.Value); + } + + [Fact] + public void ParseNamed_Date1904System_ShiftsParsedDateBy1462Days() + { + const string sheetRows = + """DateId""" + + """10001"""; + + using var ms1900 = WorkbookBuilder.Build(sheetRows, date1904: false); + using var reader1900 = Excel.From(ms1900, leaveOpen: true); + Assert.False(reader1900.IsDate1904); + var e1900 = RefParser.ParseNamed(reader1900).GetEnumerator(); + Assert.True(e1900.MoveNext()); + DateTime date1900 = e1900.Current.Date; + + using var ms1904 = WorkbookBuilder.Build(sheetRows, date1904: true); + using var reader1904 = Excel.From(ms1904, leaveOpen: true); + Assert.True(reader1904.IsDate1904); + var e1904 = RefParser.ParseNamed(reader1904).GetEnumerator(); + Assert.True(e1904.MoveNext()); + DateTime date1904Result = e1904.Current.Date; + + // ExcelRowContext.IsDate1904 must genuinely thread from the reader into NamedRefRowEnumerator + // — the same serial parses 1462 days apart (the 1904-system epoch offset) between the two + // runs. 1904-system dates are later for the same raw serial (epoch starts at OADate 1462). + Assert.Equal(1462, (date1904Result - date1900).Days); + } + + [Fact] + public async Task IEnumerableInterop_Throws() + { + await using var ms = await TypedWorkbook.BuildAsync(["Name", "Id", "Value", "Date"]); + using var reader = Excel.From(ms, leaveOpen: true); + IEnumerable enumerable = RefParser.ParseNamed(reader); + Assert.Throws(() => enumerable.GetEnumerator()); + Assert.Throws(() => ((IEnumerable)enumerable).GetEnumerator()); + } + } +} +#endif