diff --git a/README.md b/README.md index c3fdb456..c1fff146 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ while (await rows.MoveNextAsync()) ## Parse typed rows -Use `[ExcelColumn]` when the spreadsheet header does not match the property name. +`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. ```csharp using ExcelReader.Core.Parser; @@ -148,6 +148,82 @@ foreach (var item in parser.Parse(reader)) } ``` +Built-in property types: `string`, `bool`, `DateTime`, `DateOnly`, `Guid`, every integral and floating type plus `decimal`, and `enum`s (matched by member name or numeric value). Each also works as a `Nullable`. Empty cells leave the property at its default; an unparseable cell is skipped (keeps the default) unless the column is required. `T` needs no parameterless-constructor constraint, so models with `required` members are supported. + +`Parse` and `ParseAsync` also accept the `IExcelRowReader` from `Excel.Open`, so you can parse without knowing the concrete format: + +```csharp +using IExcelRowReader reader = Excel.Open("changes.xlsx"); // or .xlsb / .xls +foreach (var item in new ExcelParser().Parse(reader)) { /* ... */ } +``` + +## Parser configuration + +Pass an `ExcelParserConfig` to control header handling and culture: + +```csharp +using System.Globalization; +using ExcelReader.Core.Parser; + +var config = new ExcelParserConfig +{ + HeaderRow = 1, // 1-based row holding the headers + ColumnNameComparer = StringComparer.OrdinalIgnoreCase, + HeaderNormalization = HeaderNormalization.Trim | HeaderNormalization.CollapseSpaces, + Culture = CultureInfo.GetCultureInfo("pt-BR"), // parse "1.234,56" as 1234.56m +}; + +var parser = new ExcelParser(config); +``` + +`Culture` applies when parsing text-backed numeric/`Guid` cells (XLSX inline and shared strings); binary numeric cells (XLS/XLSB) carry a raw value and ignore it. `HeaderNormalization` flags (`Trim`, `CollapseSpaces`, `RemoveDiacritics`) are applied to both the sheet headers and the property names before matching. + +## Required columns + +Mark a property `[ExcelRequired]` to assert its column exists and carries a value: + +```csharp +public sealed class Order +{ + [ExcelRequired] + public int Id { get; set; } + + [ExcelRequired(AllowEmpty = true)] // column must exist; blank cells allowed + public string? Note { get; set; } +} +``` + +- A missing required header throws when the header row is read, listing every missing column. +- By default each data row must have a non-empty cell; the first blank throws, naming the column and row number. `AllowEmpty = true` relaxes this to column presence only. +- The check covers presence, not parseability — a present-but-malformed value does not throw here. + +## Custom converters + +For types the built-in parsers do not handle — money strings, custom formats, domain value objects — implement `IExcelCellConverter` and attach it with `[ExcelConverter]`. `T` must be the property's exact type. One instance is created and reused across all rows, so converters must be stateless. + +```csharp +using System.Globalization; +using ExcelReader.Core.Parser; +using ExcelReader.Core.ValueObjects; + +public sealed class BrlMoneyConverter : IExcelCellConverter +{ + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value) + { + string text = cell.GetString().Replace("R$", "", StringComparison.Ordinal).Trim(); + return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value); + } +} + +public sealed class Invoice +{ + [ExcelConverter(typeof(BrlMoneyConverter))] + public decimal Total { get; set; } +} +``` + +Return `false` to signal a parse failure (the property keeps its default). Empty cells are skipped before the converter runs. + ## Write XLSX workbooks ```csharp diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj index ba8e0a3c..25ed9240 100644 --- a/src/ExcelReader.Core/ExcelReader.Core.csproj +++ b/src/ExcelReader.Core/ExcelReader.Core.csproj @@ -46,5 +46,8 @@ + + + diff --git a/src/ExcelReader.Core/Parser/ExcelConverterAttribute.cs b/src/ExcelReader.Core/Parser/ExcelConverterAttribute.cs new file mode 100644 index 00000000..d61c529b --- /dev/null +++ b/src/ExcelReader.Core/Parser/ExcelConverterAttribute.cs @@ -0,0 +1,16 @@ +namespace ExcelReader.Core.Parser +{ + // Binds a custom IExcelCellConverter to a property. The converter type must implement + // IExcelCellConverter<> for the property's exact type and expose a public parameterless constructor. + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class ExcelConverterAttribute : Attribute + { + public Type ConverterType { get; } + + public ExcelConverterAttribute(Type converterType) + { + ArgumentNullException.ThrowIfNull(converterType); + ConverterType = converterType; + } + } +} diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs index 743ea2de..4bb76464 100644 --- a/src/ExcelReader.Core/Parser/ExcelParser.cs +++ b/src/ExcelReader.Core/Parser/ExcelParser.cs @@ -4,7 +4,7 @@ namespace ExcelReader.Core.Parser { - public sealed class ExcelParser where T : new() + public sealed class ExcelParser { private readonly ExcelParserConfig _config; @@ -41,6 +41,16 @@ public XlsExcelEnumerable Parse(XlsReader reader) return new ExcelEnumerable(reader, _config); } + // 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. + [SuppressMessage("Usage", "VSTHRD200:Use \"Async\" suffix for async methods", + Justification = "Synchronous entry point; the enumerable also implements IAsyncEnumerable, but ParseAsync is the async counterpart.")] + public ExcelEnumerable Parse(IExcelRowReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + return new ExcelEnumerable(reader, _config); + } + public ExcelEnumerable ParseAsync(XlsxReader reader, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(reader); @@ -58,5 +68,11 @@ public XlsExcelEnumerable ParseAsync(XlsReader reader, CancellationToken ct = ArgumentNullException.ThrowIfNull(reader); return new ExcelEnumerable(reader, _config, ct); } + + public ExcelEnumerable ParseAsync(IExcelRowReader reader, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(reader); + return new ExcelEnumerable(reader, _config, ct); + } } } diff --git a/src/ExcelReader.Core/Parser/ExcelParserConfig.cs b/src/ExcelReader.Core/Parser/ExcelParserConfig.cs index ebb9614f..7c09ff64 100644 --- a/src/ExcelReader.Core/Parser/ExcelParserConfig.cs +++ b/src/ExcelReader.Core/Parser/ExcelParserConfig.cs @@ -1,8 +1,16 @@ +using System.Globalization; + namespace ExcelReader.Core.Parser { public sealed class ExcelParserConfig { public int HeaderRow { get; init; } = 1; public StringComparer ColumnNameComparer { get; init; } = StringComparer.OrdinalIgnoreCase; + public HeaderNormalization HeaderNormalization { get; init; } = HeaderNormalization.Trim; + + // Culture used when parsing text-backed numeric and Guid cells (e.g. pt-BR "1.234,56"). + // Binary numeric cells (XLS/XLSB) carry a raw double and ignore this. Defaults to invariant + // to preserve existing behavior. + public CultureInfo Culture { get; init; } = CultureInfo.InvariantCulture; } } diff --git a/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs b/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs new file mode 100644 index 00000000..2ceb9eee --- /dev/null +++ b/src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs @@ -0,0 +1,12 @@ +namespace ExcelReader.Core.Parser +{ + // Marks a property whose column must be present in the header row (parsing throws when the header + // is read if no name matches). By default the cell must also be non-empty in every data row, with + // a per-row throw on the first blank. Set AllowEmpty = true to require only the column's presence. + // Presence/non-empty only — it does not validate that the value actually parses. + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class ExcelRequiredAttribute : Attribute + { + public bool AllowEmpty { get; set; } + } +} diff --git a/src/ExcelReader.Core/Parser/HeaderNormalization.cs b/src/ExcelReader.Core/Parser/HeaderNormalization.cs new file mode 100644 index 00000000..0e5f9126 --- /dev/null +++ b/src/ExcelReader.Core/Parser/HeaderNormalization.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using System.Text; + +namespace ExcelReader.Core.Parser +{ + [Flags] + public enum HeaderNormalization + { + None = 0, + Trim = 1 << 0, + CollapseSpaces = 1 << 1, + RemoveDiacritics = 1 << 2, + } + + internal static class HeaderNormalizationExtensions + { + internal static string Apply(this HeaderNormalization norm, string value) + { + if (norm == HeaderNormalization.None) + { + return value; + } + if (norm.HasFlag(HeaderNormalization.Trim)) + { + value = value.Trim(); + } + if (norm.HasFlag(HeaderNormalization.CollapseSpaces)) + { + value = string.Join(' ', value.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); + } + if (norm.HasFlag(HeaderNormalization.RemoveDiacritics)) + { + string nfd = value.Normalize(NormalizationForm.FormD); + value = new string(nfd.Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark).ToArray()); + } + return value; + } + } +} diff --git a/src/ExcelReader.Core/Parser/IExcelCellConverter.cs b/src/ExcelReader.Core/Parser/IExcelCellConverter.cs new file mode 100644 index 00000000..2afa056c --- /dev/null +++ b/src/ExcelReader.Core/Parser/IExcelCellConverter.cs @@ -0,0 +1,17 @@ +using ExcelReader.Core.ValueObjects; + +namespace ExcelReader.Core.Parser +{ + // Converts a matched cell into a property value. Attach to a property with + // [ExcelConverter(typeof(MyConverter))] for types the built-in parsers do not handle + // (money strings, custom date formats, domain value objects, ...). + // + // T must be the exact property type (use IExcelCellConverter for a decimal? property). + // A single instance is created once and shared across every row and thread, so implementations + // must be stateless / thread-safe. Empty cells are skipped before TryConvert runs, so the + // property keeps its default; return false to signal a parse failure (also keeps the default). + public interface IExcelCellConverter + { + bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out T value); + } +} diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 28148fc2..411154a0 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -1,9 +1,9 @@ using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Linq.Expressions; using System.Reflection; using ExcelReader.Core.Enums; using ExcelReader.Core.ValueObjects; +using FastEnumUtility; namespace ExcelReader.Core.Parser.Internal { @@ -23,13 +23,35 @@ internal static class ColumnParserFactory nameof(BuildNullableParsableCore), BindingFlags.NonPublic | BindingFlags.Static)!; + [SuppressMessage("Blocker Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "Private method accessed within same class for generic dispatch; intentional and type-safe.")] + private static readonly MethodInfo _buildEnumMethod = + typeof(ColumnParserFactory).GetMethod( + nameof(BuildEnumCore), + BindingFlags.NonPublic | BindingFlags.Static)!; + + [SuppressMessage("Blocker Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "Private method accessed within same class for generic dispatch; intentional and type-safe.")] + private static readonly MethodInfo _buildNullableEnumMethod = + typeof(ColumnParserFactory).GetMethod( + nameof(BuildNullableEnumCore), + BindingFlags.NonPublic | BindingFlags.Static)!; + + [SuppressMessage("Blocker Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "Private method accessed within same class for generic dispatch; intentional and type-safe.")] + private static readonly MethodInfo _buildConverterMethod = + typeof(ColumnParserFactory).GetMethod( + nameof(BuildConverterCore), + BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly HashSet _parsableTypes = [ typeof(int), typeof(long), typeof(double), typeof(float), typeof(decimal), typeof(short), typeof(byte), typeof(uint), typeof(ulong), typeof(ushort), + typeof(Guid), ]; - internal static ColumnParser? Build(PropertyInfo prop) where T : new() + internal static ColumnParser? Build(PropertyInfo prop) { Type propType = prop.PropertyType; Type? innerNullable = Nullable.GetUnderlyingType(propType); @@ -40,7 +62,26 @@ internal static class ColumnParserFactory return BuildConcreteParser(prop, propType); } - private static ColumnParser? BuildConcreteParser(PropertyInfo prop, Type propType) where T : new() + // Builds a parser from a user-supplied IExcelCellConverter. The converter type must + // 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) + { + Type propType = prop.PropertyType; + Type ifaceType = typeof(IExcelCellConverter<>).MakeGenericType(propType); + if (!ifaceType.IsAssignableFrom(converterType)) + { + throw new InvalidOperationException( + $"Converter '{converterType}' must implement IExcelCellConverter<{propType}> to convert property '{prop.DeclaringType?.Name}.{prop.Name}'."); + } + object converter = Activator.CreateInstance(converterType) + ?? throw new InvalidOperationException($"Converter '{converterType}' could not be instantiated."); + return (ColumnParser)_buildConverterMethod + .MakeGenericMethod(typeof(T), propType) + .Invoke(null, [prop, converter])!; + } + + private static ColumnParser? BuildConcreteParser(PropertyInfo prop, Type propType) { if (propType == typeof(string)) { @@ -54,6 +95,22 @@ internal static class ColumnParserFactory { return BuildDateTimeParser(prop); } + if (propType == typeof(DateOnly)) + { + return BuildDateOnlyParser(prop); + } +#if NET8_0 + if (propType == typeof(Guid)) + { + return BuildGuidParser(prop); + } +#endif + if (propType.IsEnum) + { + return (ColumnParser?)_buildEnumMethod + .MakeGenericMethod(typeof(T), propType) + .Invoke(null, [prop]); + } if (!_parsableTypes.Contains(propType)) { return null; @@ -63,7 +120,7 @@ internal static class ColumnParserFactory .Invoke(null, [prop]); } - private static ColumnParser? BuildNullableParser(PropertyInfo prop, Type innerType) where T : new() + private static ColumnParser? BuildNullableParser(PropertyInfo prop, Type innerType) { if (innerType == typeof(bool)) { @@ -73,6 +130,22 @@ internal static class ColumnParserFactory { return BuildNullableDateTimeParser(prop); } +#if NET8_0 + if (innerType == typeof(Guid)) + { + return BuildNullableGuidParser(prop); + } +#endif + if (innerType == typeof(DateOnly)) + { + return BuildNullableDateOnlyParser(prop); + } + if (innerType.IsEnum) + { + return (ColumnParser?)_buildNullableEnumMethod + .MakeGenericMethod(typeof(T), innerType) + .Invoke(null, [prop]); + } if (!_parsableTypes.Contains(innerType)) { return null; @@ -82,10 +155,10 @@ internal static class ColumnParserFactory .Invoke(null, [prop]); } - private static ColumnParser BuildStringParser(PropertyInfo prop) where T : new() + private static ColumnParser BuildStringParser(PropertyInfo prop) { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904) => + return (ref model, in cell, _, _) => { if (cell.Type == CellType.Empty) { @@ -96,10 +169,10 @@ internal static class ColumnParserFactory }; } - private static ColumnParser BuildBoolParser(PropertyInfo prop) where T : new() + private static ColumnParser BuildBoolParser(PropertyInfo prop) { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904) => + return (ref model, in cell, _, _) => { if (cell.Type == CellType.Empty) { @@ -110,10 +183,10 @@ internal static class ColumnParserFactory }; } - private static ColumnParser BuildDateTimeParser(PropertyInfo prop) where T : new() + private static ColumnParser BuildDateTimeParser(PropertyInfo prop) { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904) => + return (ref model, in cell, isDate1904, _) => { if (cell.Type == CellType.Empty) { @@ -128,18 +201,35 @@ internal static class ColumnParserFactory }; } + private static ColumnParser BuildDateOnlyParser(PropertyInfo prop) + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, isDate1904, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + if (!cell.TryGetDateTime(isDate1904, out DateTime dt)) + { + return false; + } + setter(ref model, DateOnly.FromDateTime(dt)); + return true; + }; + } + private static ColumnParser BuildParsableCore(PropertyInfo prop) - where T : new() where TProp : IUtf8SpanParsable { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _) => + return (ref model, in cell, _, provider) => { if (cell.Type == CellType.Empty) { return true; } - if (!cell.TryParse(CultureInfo.InvariantCulture, out TProp? value)) + if (!cell.TryParse(provider, out TProp? value)) { return false; } @@ -148,10 +238,10 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) }; } - private static ColumnParser BuildNullableBoolParser(PropertyInfo prop) where T : new() + private static ColumnParser BuildNullableBoolParser(PropertyInfo prop) { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _) => + return (ref model, in cell, _, _) => { if (cell.Type == CellType.Empty) { @@ -162,10 +252,10 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) }; } - private static ColumnParser BuildNullableDateTimeParser(PropertyInfo prop) where T : new() + private static ColumnParser BuildNullableDateTimeParser(PropertyInfo prop) { RefAction setter = CompileSetter(prop); - return (ref model, in cell, isDate1904) => + return (ref model, in cell, isDate1904, _) => { if (cell.Type == CellType.Empty) { @@ -180,20 +270,37 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) }; } + private static ColumnParser BuildNullableDateOnlyParser(PropertyInfo prop) + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, isDate1904, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + if (!cell.TryGetDateTime(isDate1904, out DateTime dt)) + { + return false; + } + setter(ref model, DateOnly.FromDateTime(dt)); + return true; + }; + } + [SuppressMessage("Blocker Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "Called via MakeGenericMethod dispatch; private access is intentional and type-safe.")] private static ColumnParser BuildNullableParsableCore(PropertyInfo prop) - where T : new() where TProp : struct, IUtf8SpanParsable { RefAction setter = CompileSetter(prop); - return (ref model, in cell, _) => + return (ref model, in cell, _, provider) => { if (cell.Type == CellType.Empty) { return true; } - if (!cell.TryParse(CultureInfo.InvariantCulture, out TProp parsed)) + if (!cell.TryParse(provider, out TProp parsed)) { return false; } @@ -203,6 +310,111 @@ private static ColumnParser BuildNullableParsableCore(PropertyInfo }; } +#if NET8_0 + // Guid does not implement IUtf8SpanParsable on all targets, so parse from the string + // form rather than the UTF-8 generic dispatch. Culture is irrelevant for Guid. + private static ColumnParser BuildGuidParser(PropertyInfo prop) + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, _, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + if (!Guid.TryParse(cell.GetString(), out Guid value)) + { + return false; + } + setter(ref model, value); + return true; + }; + } + + private static ColumnParser BuildNullableGuidParser(PropertyInfo prop) + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, _, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + if (!Guid.TryParse(cell.GetString(), out Guid value)) + { + return false; + } + setter(ref model, value); + return true; + }; + } +#endif + + // Enum.TryParse accepts both member names ("Active") and their numeric form ("2"), + // so binary-numeric and text cells both resolve. Culture is irrelevant for enums. + private static ColumnParser BuildEnumCore(PropertyInfo prop) + where TEnum : struct, Enum + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, _, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + var valueString = cell.GetString(); + if (!FastEnum.TryParse(valueString, ignoreCase: true, out TEnum value) + && !Enum.TryParse(valueString, ignoreCase: true, out value)) + { + return false; + } + setter(ref model, value); + return true; + }; + } + + private static ColumnParser BuildNullableEnumCore(PropertyInfo prop) + where TEnum : struct, Enum + { + RefAction setter = CompileSetter(prop); + return (ref model, in cell, _, _) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + var valueString = cell.GetString(); + if (!FastEnum.TryParse(valueString, ignoreCase: true, out TEnum parsed) + && !Enum.TryParse(valueString, ignoreCase: true, out parsed)) + { + return false; + } + setter(ref model, parsed); + return true; + }; + } + + // Empty cells are short-circuited here (keep default), matching every built-in parser, so the + // converter only ever sees a populated cell. + private static ColumnParser BuildConverterCore(PropertyInfo prop, object converter) + { + var typed = (IExcelCellConverter)converter; + RefAction setter = CompileSetter(prop); + return (ref model, in cell, isDate1904, provider) => + { + if (cell.Type == CellType.Empty) + { + return true; + } + if (!typed.TryConvert(in cell, isDate1904, provider, out TProp value)) + { + return false; + } + setter(ref model, value); + return true; + }; + } + private static RefAction CompileSetter(PropertyInfo prop) { ParameterExpression modelParam = Expression.Parameter(typeof(T).MakeByRefType(), "model"); diff --git a/src/ExcelReader.Core/Parser/Internal/Delegates.cs b/src/ExcelReader.Core/Parser/Internal/Delegates.cs index 3cad4a12..ace9fa78 100644 --- a/src/ExcelReader.Core/Parser/Internal/Delegates.cs +++ b/src/ExcelReader.Core/Parser/Internal/Delegates.cs @@ -13,10 +13,12 @@ internal delegate void RefAction(ref TModel model, TProper // Column-level TryParse over a cell already matched to the target column. // Returns false on parse failure; true on success or empty cell (keep default). + // provider supplies the culture for text-backed numeric/Guid cells (ExcelParserConfig.Culture). internal delegate bool ColumnParser( ref TModel model, in Cell cell, - bool isDate1904) + bool isDate1904, + IFormatProvider provider) #if NET9_0_OR_GREATER where TModel : allows ref struct; #else diff --git a/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs index 15498898..8dcb3224 100644 --- a/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs @@ -5,7 +5,7 @@ namespace ExcelReader.Core.Parser.Internal { - public sealed class ExcelEnumerable : ExcelEnumerable where T : new() + public sealed class ExcelEnumerable : ExcelEnumerable { internal ExcelEnumerable(XlsxReader reader, ExcelParserConfig config, CancellationToken ct = default) : base(reader, config, ct) @@ -16,7 +16,6 @@ internal ExcelEnumerable(XlsxReader reader, ExcelParserConfig config, Cancellati [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Public nested struct Enumerator is the standard foreach pattern.")] public class ExcelEnumerable : IEnumerable, IAsyncEnumerable - where T : new() where TReader : IExcelRowReader where TEnumerator : IExcelRowEnumerator { @@ -37,7 +36,7 @@ public Enumerator GetEnumerator() { TypeMapInfo info = TypeMapper.GetInfo(); TEnumerator rows = _reader.GetEnumerator(); - return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderRow, _reader.IsDate1904); + return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _reader.IsDate1904, _config.Culture); } IEnumerator IEnumerable.GetEnumerator() @@ -56,7 +55,7 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToke { TypeMapInfo info = TypeMapper.GetInfo(); CancellationToken effective = cancellationToken.CanBeCanceled ? cancellationToken : _ct; - return new AsyncEnumerator(_reader, info, _config.ColumnNameComparer, _config.HeaderRow, effective); + return new AsyncEnumerator(_reader, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, effective, _config.Culture); } public struct Enumerator : IEnumerator @@ -71,11 +70,13 @@ internal Enumerator( TEnumerator rows, TypeMapInfo typeInfo, StringComparer comparer, + HeaderNormalization normalization, int headerRow, - bool isDate1904) + bool isDate1904, + IFormatProvider provider) { _rows = rows; - _projector = new RowProjector(typeInfo, comparer, headerRow, isDate1904); + _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, isDate1904, provider); } public readonly T Current => _current; @@ -125,12 +126,14 @@ internal AsyncEnumerator( TReader reader, TypeMapInfo typeInfo, StringComparer comparer, + HeaderNormalization normalization, int headerRow, - CancellationToken ct) + CancellationToken ct, + IFormatProvider provider) { _reader = reader; _ct = ct; - _projector = new RowProjector(typeInfo, comparer, headerRow, reader.IsDate1904); + _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, reader.IsDate1904, provider); } public T Current => _current; diff --git a/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs b/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs index 077f1ead..5320d39b 100644 --- a/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs +++ b/src/ExcelReader.Core/Parser/Internal/HeaderMatch.cs @@ -1,6 +1,6 @@ namespace ExcelReader.Core.Parser.Internal { - internal readonly struct HeaderMatch where T : new() + internal readonly struct HeaderMatch { internal HeaderMatch(int propertyIndex, int aliasIndex, ColumnParser parser) { diff --git a/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs b/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs index 0a67f47d..a42055a0 100644 --- a/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs +++ b/src/ExcelReader.Core/Parser/Internal/PropertyMap.cs @@ -1,14 +1,20 @@ namespace ExcelReader.Core.Parser.Internal { - internal readonly struct PropertyMap where T : new() + internal readonly struct PropertyMap { - internal PropertyMap(string[] names, ColumnParser parser) + internal PropertyMap(string[] names, ColumnParser parser, bool isRequired, bool requireValue) { Names = names; Parser = parser; + IsRequired = isRequired; + RequireValue = requireValue; } internal string[] Names { get; } internal ColumnParser Parser { get; } + // The column must be present in the header. + internal bool IsRequired { get; } + // The cell must also be non-empty in every data row. + internal bool RequireValue { get; } } } diff --git a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs index ae440371..a18a73c3 100644 --- a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs +++ b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs @@ -1,22 +1,31 @@ +using ExcelReader.Core.Enums; using ExcelReader.Core.ValueObjects; namespace ExcelReader.Core.Parser.Internal { - internal struct RowProjector where T : new() + internal struct RowProjector { private readonly TypeMapInfo _typeInfo; private readonly StringComparer _comparer; + private readonly HeaderNormalization _normalization; private readonly int _headerRow; private readonly bool _isDate1904; + private readonly IFormatProvider _provider; private ColumnBinding[]? _bindings; + // Per-row scratch: _seen[i] is set when binding i saw a non-empty cell this row. Only allocated + // and walked when at least one bound column requires a value (_requireValueCount > 0). + private bool[]? _seen; + private int _requireValueCount; private int _rowNumber; - internal RowProjector(TypeMapInfo typeInfo, StringComparer comparer, int headerRow, bool isDate1904) + internal RowProjector(TypeMapInfo typeInfo, StringComparer comparer, HeaderNormalization normalization, int headerRow, bool isDate1904, IFormatProvider provider) { _typeInfo = typeInfo; _comparer = comparer; + _normalization = normalization; _headerRow = headerRow; _isDate1904 = isDate1904; + _provider = provider; } // The per-row state machine shared by every enumerator (sync/async, xlsx/xls): skip rows before @@ -37,7 +46,7 @@ internal ProjectionStep Advance(in Row row, ref T model) { return ProjectionStep.Stop; } - model = new T(); + model = _typeInfo.CreateInstance(); ParseCurrentRow(in row, ref model); return ProjectionStep.Yield; } @@ -54,12 +63,12 @@ private void BuildColumnMap(in Row row) foreach (RowCell rowCell in row.Cells) { Cell cell = rowCell.Value; - string header = cell.GetString(); + string header = _normalization.Apply(cell.GetString()); if (string.IsNullOrEmpty(header)) { continue; } - if (!_typeInfo.TryFindHeader(header, _comparer, out HeaderMatch match)) + if (!_typeInfo.TryFindHeader(header, _comparer, _normalization, out HeaderMatch match)) { continue; } @@ -76,23 +85,38 @@ private void BuildColumnMap(in Row row) aliasIndexes[match.PropertyIndex] = match.AliasIndex; } + _typeInfo.ValidateRequiredColumns(aliasIndexes); + var bindings = new ColumnBinding[bindingCount]; int index = 0; + int requireValueCount = 0; for (int i = 0; i < parsers.Length; i++) { ColumnParser? parser = parsers[i]; if (parser is not null) { - bindings[index++] = new ColumnBinding(columns[i], parser); + bool requireValue = _typeInfo.RequiresValue(i); + if (requireValue) + { + requireValueCount++; + } + bindings[index++] = new ColumnBinding(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] : null; } private readonly void ParseCurrentRow(in Row row, ref T model) { ColumnBinding[] bindings = _bindings!; + bool track = _requireValueCount > 0; + if (track) + { + Array.Clear(_seen!, 0, bindings.Length); + } int bindingIndex = 0; foreach (RowCell rowCell in row.Cells) { @@ -103,29 +127,53 @@ private readonly void ParseCurrentRow(in Row row, ref T model) } if (bindingIndex == bindings.Length) { - return; + break; } ColumnBinding binding = bindings[bindingIndex]; if (binding.Column == column) { Cell cell = rowCell.Value; - binding.Parser(ref model, in cell, _isDate1904); + binding.Parser(ref model, in cell, _isDate1904, _provider); + if (track && binding.RequireValue && cell.Type != CellType.Empty) + { + _seen![bindingIndex] = true; + } bindingIndex++; } } + if (track) + { + ValidateRowValues(bindings); + } + } + + // Throws on the first required column whose cell was empty or absent in the current row. + private readonly void ValidateRowValues(ColumnBinding[] bindings) + { + for (int i = 0; i < bindings.Length; i++) + { + if (bindings[i].RequireValue && !_seen![i]) + { + throw new InvalidOperationException( + $"Required column '{bindings[i].Name}' has no value in row {_rowNumber.ToString(System.Globalization.CultureInfo.InvariantCulture)}."); + } + } } private readonly struct ColumnBinding - where TModel : new() { - internal ColumnBinding(int column, ColumnParser parser) + internal ColumnBinding(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; } } } } diff --git a/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs b/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs index de23cd1d..8dd835cb 100644 --- a/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs +++ b/src/ExcelReader.Core/Parser/Internal/TypeMapInfo.cs @@ -2,35 +2,68 @@ namespace ExcelReader.Core.Parser.Internal { - internal readonly struct TypeMapInfo where T : new() + internal readonly struct TypeMapInfo { private readonly PropertyMap[] _properties; - private readonly ConcurrentDictionary>> _lookupCache; + private readonly Func _factory; + private readonly ConcurrentDictionary<(StringComparer, HeaderNormalization), Dictionary>> _lookupCache; - internal TypeMapInfo(PropertyMap[] properties) + internal TypeMapInfo(PropertyMap[] properties, Func factory) { _properties = properties; - _lookupCache = new ConcurrentDictionary>>(); + _factory = factory; + _lookupCache = new ConcurrentDictionary<(StringComparer, HeaderNormalization), Dictionary>>(); } internal int PropertyCount => _properties.Length; - internal bool TryFindHeader(string headerName, StringComparer comparer, out HeaderMatch match) + // Creates a fresh model instance per row without a `where T : new()` constraint, so types with + // required members (which the new() constraint forbids) can still be parsed. + internal T CreateInstance() => _factory(); + + internal bool RequiresValue(int propertyIndex) => _properties[propertyIndex].RequireValue; + + internal string DisplayName(int propertyIndex) => _properties[propertyIndex].Names[0]; + + // Throws if any [ExcelRequired] property was left unmatched after the header row was mapped. + // unmatched[i] is int.MaxValue when property i found no header column (RowProjector's sentinel). + internal void ValidateRequiredColumns(int[] unmatched) + { + List? missing = null; + for (int i = 0; i < _properties.Length; i++) + { + if (_properties[i].IsRequired && unmatched[i] == int.MaxValue) + { + (missing ??= []).Add(_properties[i].Names[0]); + } + } + if (missing is not null) + { + throw new InvalidOperationException( + $"Required column(s) not found in the header row: {string.Join(", ", missing)}."); + } + } + + internal bool TryFindHeader(string headerName, StringComparer comparer, HeaderNormalization normalization, out HeaderMatch match) { - var lookup = _lookupCache.GetOrAdd(comparer, BuildLookup); + PropertyMap[] properties = _properties; + var lookup = _lookupCache.GetOrAdd( + (comparer, normalization), + static (key, props) => BuildLookup(props, key.Item1, key.Item2), + properties); return lookup.TryGetValue(headerName, out match); } - private Dictionary> BuildLookup(StringComparer comparer) + private static Dictionary> BuildLookup(PropertyMap[] properties, StringComparer comparer, HeaderNormalization normalization) { Dictionary> lookup = new(comparer); - for (int propertyIndex = 0; propertyIndex < _properties.Length; propertyIndex++) + for (int propertyIndex = 0; propertyIndex < properties.Length; propertyIndex++) { - PropertyMap property = _properties[propertyIndex]; + PropertyMap property = properties[propertyIndex]; for (int aliasIndex = 0; aliasIndex < property.Names.Length; aliasIndex++) { lookup.TryAdd( - property.Names[aliasIndex], + normalization.Apply(property.Names[aliasIndex]), new(propertyIndex, aliasIndex, property.Parser)); } } diff --git a/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs b/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs index 7de78a77..158a61da 100644 --- a/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs +++ b/src/ExcelReader.Core/Parser/Internal/TypeMapper.cs @@ -1,9 +1,10 @@ +using System.Linq.Expressions; using System.Reflection; using System.Runtime.ExceptionServices; namespace ExcelReader.Core.Parser.Internal { - internal static class TypeMapper where T : new() + internal static class TypeMapper { private static readonly Lazy> _info = new(BuildSafe, LazyThreadSafetyMode.ExecutionAndPublication); @@ -40,19 +41,35 @@ private static TypeMapInfo Build() { continue; } - var parser = ColumnParserFactory.Build(prop); + ExcelRequiredAttribute? requiredAttr = prop.GetCustomAttribute(); + bool isRequired = requiredAttr is not null; + bool requireValue = isRequired && !requiredAttr!.AllowEmpty; + ExcelConverterAttribute? converterAttr = prop.GetCustomAttribute(); + ColumnParser? parser = converterAttr is not null + ? ColumnParserFactory.BuildConverter(prop, converterAttr.ConverterType) + : ColumnParserFactory.Build(prop); if (parser is null) { + if (isRequired) + { + // A required column with no parser could never bind, so its requirement would + // be impossible to satisfy — surface that as a configuration error up front. + throw new InvalidOperationException( + $"Property '{typeof(T).Name}.{prop.Name}' is marked [ExcelRequired] but its type '{prop.PropertyType}' has no parser. Add an [ExcelConverter] for it."); + } continue; } ExcelColumnAttribute[] attrs = [.. prop.GetCustomAttributes()]; string[] names = attrs.Length == 0 ? [prop.Name] : [.. attrs.Select(static attr => attr.Name)]; - propertyMaps.Add(new PropertyMap(names, parser)); + propertyMaps.Add(new PropertyMap(names, parser, isRequired, requireValue)); } - return new TypeMapInfo([.. propertyMaps]); + // Compiled once per type — the per-row instance factory that replaces `new T()`, so the + // parser no longer needs a `where T : new()` constraint (and types with required members work). + Func factory = Expression.Lambda>(Expression.New(typeof(T))).Compile(); + return new TypeMapInfo([.. propertyMaps], factory); } } } diff --git a/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs index ecfdae40..8f93b0bb 100644 --- a/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs +++ b/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs @@ -7,7 +7,7 @@ namespace ExcelReader.Core.Parser.Internal { [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Public nested struct Enumerator is the standard foreach pattern.")] - public sealed class XlsExcelEnumerable : IEnumerable, IAsyncEnumerable where T : new() + public sealed class XlsExcelEnumerable : IEnumerable, IAsyncEnumerable { private readonly XlsReader _reader; private readonly ExcelParserConfig _config; @@ -26,7 +26,7 @@ public Enumerator GetEnumerator() { TypeMapInfo info = TypeMapper.GetInfo(); XlsReader.Enumerator rows = _reader.GetEnumerator(); - return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderRow, _reader.IsDate1904); + return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _reader.IsDate1904, _config.Culture); } IEnumerator IEnumerable.GetEnumerator() @@ -44,7 +44,7 @@ public Enumerator GetAsyncEnumerator(CancellationToken cancellationToken = defau TypeMapInfo info = TypeMapper.GetInfo(); CancellationToken effective = cancellationToken.CanBeCanceled ? cancellationToken : _ct; XlsReader.Enumerator rows = _reader.GetAsyncEnumerator(effective); - return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderRow, _reader.IsDate1904); + return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _reader.IsDate1904, _config.Culture); } [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP005:Return type should indicate that the value should be disposed", @@ -64,11 +64,13 @@ internal Enumerator( XlsReader.Enumerator rows, TypeMapInfo typeInfo, StringComparer comparer, + HeaderNormalization normalization, int headerRow, - bool isDate1904) + bool isDate1904, + IFormatProvider provider) { _rows = rows; - _projector = new RowProjector(typeInfo, comparer, headerRow, isDate1904); + _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, isDate1904, provider); } public readonly T Current => _current; diff --git a/src/ExcelReader.Core/Reader/IExcelRowReader.cs b/src/ExcelReader.Core/Reader/IExcelRowReader.cs index 2638d658..3357843b 100644 --- a/src/ExcelReader.Core/Reader/IExcelRowReader.cs +++ b/src/ExcelReader.Core/Reader/IExcelRowReader.cs @@ -10,11 +10,10 @@ public interface IExcelRowReader ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = default); } - public interface IExcelRowReader : IDisposable, IAsyncDisposable + // The non-generic reader is the generic one specialized to the interface enumerator, plus + // disposal. Unifying them lets the typed parser drive a format-agnostic reader (Excel.Open). + public interface IExcelRowReader : IExcelRowReader, IDisposable, IAsyncDisposable { - bool IsDate1904 { get; } - IExcelRowEnumerator GetEnumerator(); - ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = default); } public interface IExcelRowEnumerator : IDisposable, IAsyncDisposable diff --git a/src/ExcelReader.Core/Reader/XlsReader.cs b/src/ExcelReader.Core/Reader/XlsReader.cs index 831b1b73..ea6a1a32 100644 --- a/src/ExcelReader.Core/Reader/XlsReader.cs +++ b/src/ExcelReader.Core/Reader/XlsReader.cs @@ -95,7 +95,7 @@ public Enumerator GetEnumerator() return new Enumerator(this, _sheets[_current].Offset); } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() + IExcelRowEnumerator IExcelRowReader.GetEnumerator() { return GetEnumerator(); } @@ -114,7 +114,7 @@ public ValueTask GetAsyncEnumeratorAsync(CancellationToken ct = defa return new ValueTask(new Enumerator(this, _sheets[_current].Offset, ct)); } - ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) + 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 5c0ed909..e40e1d08 100644 --- a/src/ExcelReader.Core/Reader/XlsbReader.cs +++ b/src/ExcelReader.Core/Reader/XlsbReader.cs @@ -171,7 +171,7 @@ public Enumerator GetEnumerator() return new Enumerator(this, entry.Open()); } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() + IExcelRowEnumerator IExcelRowReader.GetEnumerator() { return GetEnumerator(); } @@ -189,7 +189,7 @@ public async ValueTask GetAsyncEnumeratorAsync(CancellationToken ct return new Enumerator(this, sheet, ct); } - async ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) + async ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) { return await GetAsyncEnumeratorAsync(ct).ConfigureAwait(false); } diff --git a/src/ExcelReader.Core/Reader/XlsxReader.cs b/src/ExcelReader.Core/Reader/XlsxReader.cs index 4ca7e2a3..68cb48dd 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.cs @@ -143,7 +143,7 @@ public Enumerator GetEnumerator() return new Enumerator(this, entry.Open()); } - IExcelRowEnumerator IExcelRowReader.GetEnumerator() + IExcelRowEnumerator IExcelRowReader.GetEnumerator() { return GetEnumerator(); } @@ -170,7 +170,7 @@ public async ValueTask GetAsyncEnumeratorAsync(CancellationToken ct return new Enumerator(this, sheet, ct); } - async ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) + async ValueTask IExcelRowReader.GetAsyncEnumeratorAsync(CancellationToken ct) { return await GetAsyncEnumeratorAsync(ct).ConfigureAwait(false); } diff --git a/tests/ExcelReader.Tests/ConverterTests.cs b/tests/ExcelReader.Tests/ConverterTests.cs new file mode 100644 index 00000000..ff03e93c --- /dev/null +++ b/tests/ExcelReader.Tests/ConverterTests.cs @@ -0,0 +1,183 @@ +using System.Globalization; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.ValueObjects; + +namespace ExcelReader.Tests +{ + // Covers custom [ExcelConverter] support: domain value objects, culture-aware parsing, + // failure-keeps-default, nullable targets, the isDate1904 plumbing, and type validation. + public class ConverterTests + { + public readonly record struct Percent(double Fraction); + + // Parses Brazilian money like "R$ 1.234,56" → 1234.56m. + private sealed class BrlMoneyConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value) + { + string text = cell.GetString().Replace("R$", string.Empty, StringComparison.Ordinal).Trim(); + return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value); + } + } + + // "12.5%" or a raw number → Percent. Uses the configured culture for the numeric part. + private sealed class PercentConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out Percent value) + { + string text = cell.GetString().TrimEnd('%'); + if (double.TryParse(text, NumberStyles.Any, provider, out double pct)) + { + value = new Percent(pct / 100.0); + return true; + } + value = default; + return false; + } + } + + private sealed class NullablePercentConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out Percent? value) + { + string text = cell.GetString().TrimEnd('%'); + if (double.TryParse(text, NumberStyles.Any, provider, out double pct)) + { + value = new Percent(pct / 100.0); + return true; + } + value = null; + return false; + } + } + + // Reads the year off a serial-date cell, honoring the workbook's 1904 epoch flag. + private sealed class YearConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out int value) + { + if (cell.TryGetDateTime(isDate1904, out DateTime dt)) + { + value = dt.Year; + return true; + } + value = 0; + return false; + } + } + + private sealed class InvoiceRow + { + [ExcelConverter(typeof(BrlMoneyConverter))] + public decimal Total { get; set; } + + [ExcelConverter(typeof(PercentConverter))] + public Percent Tax { get; set; } + + public string? Ref { get; set; } + } + + private sealed class NullableRow + { + [ExcelConverter(typeof(NullablePercentConverter))] + public Percent? Tax { get; set; } + } + + private sealed class DatedRow + { + [ExcelConverter(typeof(YearConverter))] + public int Year { get; set; } + } + + // Converter target type does not match the property type → must throw when the map builds. + private sealed class MismatchedConverter : IExcelCellConverter + { + public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out int value) + { + value = 0; + return false; + } + } + + private sealed class BadRow + { + [ExcelConverter(typeof(MismatchedConverter))] + public string? Name { get; set; } + } + + [Fact] + public async Task ConvertersParseDomainValues() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Total", "Tax", "Ref"], + ["R$ 1.234,56", "12.5%", "INV-1"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvoiceRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(1234.56m, row.Total); + Assert.Equal(0.125, row.Tax.Fraction, precision: 10); + Assert.Equal("INV-1", row.Ref); + } + + [Fact] + public async Task ConverterFailureKeepsDefault() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Total", "Tax", "Ref"], + ["garbage", "nope", "INV-2"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvoiceRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(0m, row.Total); + Assert.Equal(default, row.Tax); + Assert.Equal("INV-2", row.Ref); + } + + [Fact] + public async Task NullableConverterSetsAndLeavesNull() + { + await using var ms = await TypedWorkbook.BuildMultiSheetAsync( + ("S1", [["Tax"], ["33%"], ["bad"], [null]])); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + List rows = new ExcelParser().Parse(reader).ToList(); + + Assert.Equal(3, rows.Count); + Assert.Equal(0.33, rows[0].Tax!.Value.Fraction, precision: 10); + Assert.Null(rows[1].Tax); // unparseable → default + Assert.Null(rows[2].Tax); // empty cell → skipped, default + } + + [Fact] + public async Task ConverterReceivesDate1904Flag() + { + // 1904 workbook: serial 0 = 1904-01-01. The converter must apply the epoch shift. + const string styles = + """"""; + using var ms = WorkbookBuilder.Build( + """Year0""", + styles: styles, + date1904: true); + using var reader = Excel.From(ms); + Assert.True(reader.IsDate1904); + + DatedRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(1904, row.Year); + } + + [Fact] + public async Task MismatchedConverterTypeThrows() + { + await using var ms = await TypedWorkbook.BuildAsync(["Name"], ["x"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvalidOperationException ex = Assert.Throws( + () => new ExcelParser().Parse(reader).ToList()); + Assert.Contains("IExcelCellConverter", ex.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/ExcelReader.Tests/CoverageGapTests.cs b/tests/ExcelReader.Tests/CoverageGapTests.cs new file mode 100644 index 00000000..479d82f5 --- /dev/null +++ b/tests/ExcelReader.Tests/CoverageGapTests.cs @@ -0,0 +1,472 @@ +using System.Globalization; +using ExcelReader.Core.Enums; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; +using ExcelReader.Core.ValueObjects; +using ExcelReader.Core.Writer; +using B = ExcelReader.Tests.Biff12Build; + +namespace ExcelReader.Tests +{ + // Targets coverage gaps left by the format-specific suites: Cell text/float paths, + // header normalization flags, the typed write overloads of the XLS/XLSB row writers, + // workbook-writer lifecycle errors, and XLSB reader navigation/disposal. + public class CoverageGapTests + { + private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; + + // ISpanFormattable but NOT IConvertible — forces the XLSB writer's format-then-parse fallback. + private readonly struct Formattable(double value) : ISpanFormattable + { + public string ToString(string? format, IFormatProvider? formatProvider) + { + return value.ToString(formatProvider); + } + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return value.TryFormat(destination, out charsWritten, format, provider); + } + } + + // --- Cell --- + + [Fact] + public void CellTryFormatWritesBinaryNumber() + { + var cell = new Cell(CellType.Number, default, 42.5, hasNumber: true, 0); + Span buf = stackalloc byte[32]; + Assert.True(cell.TryFormat(buf, out int written)); + Assert.Equal("42.5", System.Text.Encoding.UTF8.GetString(buf[..written])); + } + + [Fact] + public void CellTryFormatCopiesTextValue() + { + var cell = new Cell(CellType.ExcelString, "hi"u8); + Span buf = stackalloc byte[8]; + Assert.True(cell.TryFormat(buf, out int written)); + Assert.Equal("hi", System.Text.Encoding.UTF8.GetString(buf[..written])); + } + + [Fact] + public void CellTryFormatReturnsFalseWhenDestinationTooSmall() + { + Span tiny = stackalloc byte[2]; + Assert.False(new Cell(CellType.Number, default, 12345.0, hasNumber: true, 0).TryFormat(tiny, out _)); + Assert.False(new Cell(CellType.ExcelString, "hello"u8).TryFormat(tiny, out _)); + } + + [Fact] + public void CellTryParseFloatFromBinaryNumber() + { + var cell = new Cell(CellType.Number, default, 2.5, hasNumber: true, 0); + Assert.True(cell.TryParse(Inv, out float f)); + Assert.Equal(2.5f, f); + } + + // --- HeaderNormalization --- + + [Fact] + public void HeaderNormalizationNoneReturnsValueUnchanged() + { + Assert.Equal(" Café ", HeaderNormalization.None.Apply(" Café ")); + } + + [Fact] + public void HeaderNormalizationCollapsesWhitespace() + { + const HeaderNormalization norm = HeaderNormalization.Trim | HeaderNormalization.CollapseSpaces; + Assert.Equal("a b c", norm.Apply(" a b\t\r\nc ")); + } + + [Fact] + public void HeaderNormalizationRemovesDiacritics() + { + Assert.Equal("Cafe ond", HeaderNormalization.RemoveDiacritics.Apply("Café ônd")); + } + + // --- XLSB row writer typed overloads --- + + private static async Task WriteXlsbAsync(Func build, bool date1904 = false) + { + var ms = new MemoryStream(); + await using var wb = await XlsbWorkbookWriter.CreateAsync(ms, leaveOpen: true, date1904: date1904, ct: TestContext.Current.CancellationToken); + await wb.StartAsync(TestContext.Current.CancellationToken); + await build(wb); + await wb.EndAsync(TestContext.Current.CancellationToken); + ms.Position = 0; + return ms; + } + + [Fact] + public async Task XlsbRowWriterWritesNullableAndGenericValues() + { + var date = new DateTime(2001, 2, 3, 0, 0, 0, DateTimeKind.Unspecified); + await using MemoryStream ms = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using XlsbRowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken); + row.Write((bool?)true); // col 0 + row.Write((bool?)null); // col 1 → empty + row.Write((DateTime?)date); // col 2 + row.Write((DateTime?)null); // col 3 → empty + row.Write((int?)7); // col 4 + row.Write((int?)null); // col 5 → empty + row.Write((double?)2.5); // col 6 + row.Write(new Formattable(3.5)); // col 7 → non-IConvertible fallback + }); + + await using var reader = Excel.FromXlsb(ms); + using XlsbReader.Enumerator e = reader.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal(CellType.Boolean, e.Current[0].Type); + Assert.Equal("1", e.Current[0].GetString()); + Assert.Equal(CellType.Empty, e.Current[1].Type); + Assert.Equal(CellType.Date, e.Current[2].Type); + Assert.True(e.Current[2].TryGetDateTime(reader.IsDate1904, out DateTime parsed)); + Assert.Equal(date, parsed); + Assert.Equal(CellType.Empty, e.Current[3].Type); + Assert.True(e.Current[4].TryGetDouble(out double seven)); + Assert.Equal(7.0, seven); + Assert.Equal(CellType.Empty, e.Current[5].Type); + Assert.True(e.Current[6].TryGetDouble(out double half)); + Assert.Equal(2.5, half); + Assert.True(e.Current[7].TryGetDouble(out double frac)); + Assert.Equal(3.5, frac); + } + + [Fact] + public async Task XlsbRowWriterSkipNegativeThrows() + { + await using MemoryStream ms = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using XlsbRowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken); + Assert.Throws(() => row.Skip(-1)); + row.Write("x"); + }); + + await using var reader = Excel.FromXlsb(ms); + using XlsbReader.Enumerator e = reader.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal("x", e.Current[0].GetString()); + } + + // --- XLS row writer nullable overloads --- + + [Fact] + public async Task XlsRowWriterWritesNullableValues() + { + var date = new DateTime(1999, 12, 31, 0, 0, 0, DateTimeKind.Unspecified); + var ms = new MemoryStream(); + await using (XlsWorkbookWriter wb = XlsWorkbookWriter.Create(ms, leaveOpen: true)) + { + wb.Start(); + XlsSheetWriter sheet = wb.AddSheet("S1"); + sheet.Start(); + using (XlsRowWriter row = sheet.StartRow()) + { + row.Write((bool?)true); // col 0 + row.Write((bool?)null); // col 1 → empty + row.Write((DateTime?)date); // col 2 + row.Write((DateTime?)null); // col 3 → empty + row.Write((int?)11); // col 4 + row.Write((int?)null); // col 5 → empty + } + sheet.End(); + await wb.EndAsync(TestContext.Current.CancellationToken); + } + + ms.Position = 0; + using var reader = Excel.FromXls(ms); + using var e = reader.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal(CellType.Boolean, e.Current[0].Type); + Assert.Equal(CellType.Empty, e.Current[1].Type); + Assert.Equal(CellType.Date, e.Current[2].Type); + Assert.True(e.Current[2].TryGetDateTime(out DateTime parsed)); + Assert.Equal(date, parsed); + Assert.Equal(CellType.Empty, e.Current[3].Type); + Assert.True(e.Current[4].TryParse(Inv, out int eleven)); + Assert.Equal(11, eleven); + Assert.Equal(CellType.Empty, e.Current[5].Type); + } + + // --- XLSB workbook writer lifecycle --- + + [Fact] + public async Task XlsbWorkbookWriterLifecycleErrors() + { + await using var wb = await XlsbWorkbookWriter.CreateAsync(new MemoryStream(), ct: TestContext.Current.CancellationToken); + Assert.Throws(() => wb.AddSheet("S1")); // before Start + + await wb.StartAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(async () => await wb.StartAsync(TestContext.Current.CancellationToken)); + Assert.Throws(() => wb.AddSheet("")); + XlsbSheetWriter s1 = wb.AddSheet("S1"); + Assert.Throws(() => wb.AddSheet("S2")); // previous not ended + + // End the sheet so the writer disposes cleanly (it needs at least one registered sheet). + await s1.StartAsync(TestContext.Current.CancellationToken); + await s1.EndAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task XlsbWorkbookWriterEndWithoutSheetsThrows() + { + await using var wb = await XlsbWorkbookWriter.CreateAsync(new MemoryStream(), ct: TestContext.Current.CancellationToken); + await wb.StartAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(async () => await wb.EndAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task XlsbWorkbookWriterFlushDoesNotThrow() + { + await using var wb = await XlsbWorkbookWriter.CreateAsync(new MemoryStream(), leaveOpen: true, ct: TestContext.Current.CancellationToken); + await wb.StartAsync(TestContext.Current.CancellationToken); + await wb.FlushAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task XlsbWorkbookWriterDisposeFinalizesActiveSheetAndClosesStream() + { + var ms = new TrackingStream(); + await using (var wb = await XlsbWorkbookWriter.CreateAsync(ms, leaveOpen: false, ct: TestContext.Current.CancellationToken)) + { + await wb.StartAsync(TestContext.Current.CancellationToken); + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using XlsbRowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken); + row.Write("v"); + // No EndAsync on sheet or workbook — disposal must finalize both. + } + Assert.True(ms.Disposed); + } + + [Fact] + public async Task XlsbWorkbookWriterDisposeWithoutStartDisposesStream() + { + var ms = new TrackingStream(); + await using (await XlsbWorkbookWriter.CreateAsync(ms, leaveOpen: false, ct: TestContext.Current.CancellationToken)) + { + // Never started. + } + Assert.True(ms.Disposed); + } + + [Fact] + public async Task XlsbSheetWriterDisposeAutoEnds() + { + await using MemoryStream ms = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using (XlsbRowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken)) + { + row.Write("auto"); + } + await sheet.DisposeAsync(); // no explicit EndAsync + }); + + await using var reader = Excel.FromXlsb(ms); + using XlsbReader.Enumerator e = reader.GetEnumerator(); + Assert.True(e.MoveNext()); + Assert.Equal("auto", e.Current[0].GetString()); + } + + // --- XLS workbook writer lifecycle --- + + [Fact] + public async Task XlsWorkbookWriterLifecycleErrors() + { + await using var wb = XlsWorkbookWriter.Create(new MemoryStream()); + Assert.Throws(() => wb.AddSheet("S1")); // before Start + await Assert.ThrowsAsync(async () => await wb.EndAsync(TestContext.Current.CancellationToken)); // before Start + + wb.Start(); + Assert.Throws(wb.Start); // double Start + Assert.Throws(() => wb.AddSheet("")); + wb.AddSheet("S1"); + Assert.Throws(() => wb.AddSheet("S2")); // previous not ended + } + + [Fact] + public async Task XlsWorkbookWriterFlushDoesNotThrow() + { + await using var wb = XlsWorkbookWriter.Create(new MemoryStream(), leaveOpen: true); + wb.Start(); + await wb.FlushAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task XlsWorkbookWriterDisposeFinalizesAndClosesStream() + { + var ms = new TrackingStream(); + await using (var wb = XlsWorkbookWriter.Create(ms, leaveOpen: false)) + { + wb.Start(); + XlsSheetWriter sheet = wb.AddSheet("S1"); + sheet.Start(); + using (XlsRowWriter row = sheet.StartRow()) + { + row.Write("v"); + } + // No End on sheet or workbook — disposal must finalize and close the stream. + } + Assert.True(ms.Disposed); + } + + [Fact] + public async Task XlsWorkbookWriterDoubleDisposeIsNoOp() + { + var wb = XlsWorkbookWriter.Create(new MemoryStream(), leaveOpen: true); + wb.Start(); + await wb.DisposeAsync(); + await wb.DisposeAsync(); // second dispose returns immediately + } + + // --- XLSB reader navigation & disposal --- + + [Fact] + public async Task XlsbReaderNavigatesMultipleSheets() + { + await using MemoryStream ms = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter first = wb.AddSheet("First"); + await first.StartAsync(TestContext.Current.CancellationToken); + await using (XlsbRowWriter r = await first.StartRowAsync(TestContext.Current.CancellationToken)) { r.Write("a"); } + await first.EndAsync(TestContext.Current.CancellationToken); + + XlsbSheetWriter second = wb.AddSheet("Second"); + await second.StartAsync(TestContext.Current.CancellationToken); + await using (XlsbRowWriter r = await second.StartRowAsync(TestContext.Current.CancellationToken)) { r.Write("b"); } + await second.EndAsync(TestContext.Current.CancellationToken); + }); + + await using var reader = Excel.FromXlsb(ms); + Assert.Equal(2, reader.SheetCount); + Assert.Equal("First", reader.SheetName); + + reader.MoveToSheet(1); + Assert.Equal("Second", reader.SheetName); + Assert.True(reader.TryMoveToSheet("First")); + Assert.Equal("First", reader.SheetName); + Assert.False(reader.TryMoveToSheet("Missing")); + Assert.Throws(() => reader.MoveToSheet(5)); + } + + [Fact] + public async Task XlsbReaderRowReaderInterfaceEnumeratesSyncAndAsync() + { + await using MemoryStream ms = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using (XlsbRowWriter r = await sheet.StartRowAsync(TestContext.Current.CancellationToken)) { r.Write("iface"); } + await sheet.EndAsync(TestContext.Current.CancellationToken); + }); + + await using var reader = Excel.FromXlsb(ms); + IExcelRowReader rowReader = reader; + + using (IExcelRowEnumerator sync = rowReader.GetEnumerator()) + { + Assert.True(sync.MoveNext()); + Assert.Equal("iface", sync.Current[0].GetString()); + } + + await using IExcelRowEnumerator async = await rowReader.GetAsyncEnumeratorAsync(TestContext.Current.CancellationToken); + Assert.True(await async.MoveNextAsync()); + Assert.Equal("iface", async.Current[0].GetString()); + } + + [Fact] + public async Task XlsbReaderLeaveOpenFalseClosesStream() + { + byte[] bytes; + await using (MemoryStream src = await WriteXlsbAsync(async wb => + { + XlsbSheetWriter sheet = wb.AddSheet("S1"); + await sheet.StartAsync(TestContext.Current.CancellationToken); + await using (XlsbRowWriter r = await sheet.StartRowAsync(TestContext.Current.CancellationToken)) { r.Write("x"); } + await sheet.EndAsync(TestContext.Current.CancellationToken); + })) + { + bytes = src.ToArray(); + } + + var tracking = new TrackingStream(); + tracking.Write(bytes); + tracking.Position = 0; + using (Excel.FromXlsb(tracking, leaveOpen: false)) + { + // reader takes ownership + } + Assert.True(tracking.Disposed); + } + + [Fact] + public void XlsbReaderOpenFailureDisposesStream() + { + var tracking = new TrackingStream(); + using (var zip = new System.IO.Compression.ZipArchive(tracking, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + // A ZIP with no workbook part → ParseSheets yields zero sheets → throws. + zip.CreateEntry("ignored.txt"); + } + tracking.Position = 0; + + Assert.Throws(() => Excel.FromXlsb(tracking, leaveOpen: false)); + Assert.True(tracking.Disposed); + } + + [Fact] + public async Task XlsbReaderOpenFailureAsyncDisposesStream() + { + var tracking = new TrackingStream(); + using (var zip = new System.IO.Compression.ZipArchive(tracking, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + zip.CreateEntry("ignored.txt"); + } + tracking.Position = 0; + + await Assert.ThrowsAsync(async () => + await Excel.FromXlsbAsync(tracking, leaveOpen: false, ct: TestContext.Current.CancellationToken)); + Assert.True(tracking.Disposed); + } + + [Fact] + public void XlsbReaderSharedAtOutOfRangeYieldsEmpty() + { + // CellIsst referencing index 5 when only 1 shared string exists → SharedAt returns (0,0). + var reader = new XlsbReader( + sharedFlat: System.Text.Encoding.UTF8.GetBytes("only"), + sharedOffsets: [0, 4], + styleIsDate: [], + date1904: false); + byte[] sheet = + [ + .. B.Record(Brt.RowHdr), + .. B.Record(Brt.CellIsst, B.CellIsst(0, 0, 5)), + ]; + using XlsbReader.Enumerator e = reader.GetEnumerator(new MemoryStream(sheet)); + Assert.True(e.MoveNext()); + Assert.Equal(string.Empty, e.Current[0].GetString()); + } + + // Tracks whether the stream was disposed; used to assert leaveOpen semantics. + private sealed class TrackingStream : MemoryStream + { + internal bool Disposed { get; private set; } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/tests/ExcelReader.Tests/ExcelOpenAndOleErrorTests.cs b/tests/ExcelReader.Tests/ExcelOpenAndOleErrorTests.cs index bf8ffbed..1152189a 100644 --- a/tests/ExcelReader.Tests/ExcelOpenAndOleErrorTests.cs +++ b/tests/ExcelReader.Tests/ExcelOpenAndOleErrorTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using ExcelReader.Core.Reader; namespace ExcelReader.Tests @@ -230,16 +231,19 @@ public override bool CanSeek get { return false; } } + [ExcludeFromCodeCoverage] public override bool CanWrite { get { return false; } } + [ExcludeFromCodeCoverage] public override long Length { get { throw new NotSupportedException(); } } + [ExcludeFromCodeCoverage] public override long Position { get { return _inner.Position; } @@ -251,20 +255,24 @@ public override int Read(byte[] buffer, int offset, int count) return _inner.Read(buffer, offset, count); } + [ExcludeFromCodeCoverage] public override void Flush() { } + [ExcludeFromCodeCoverage] public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } + [ExcludeFromCodeCoverage] public override void SetLength(long value) { throw new NotSupportedException(); } + [ExcludeFromCodeCoverage] public override void Write(byte[] buffer, int offset, int count) { throw new NotSupportedException(); diff --git a/tests/ExcelReader.Tests/ParserFeatureTests.cs b/tests/ExcelReader.Tests/ParserFeatureTests.cs new file mode 100644 index 00000000..23e232bc --- /dev/null +++ b/tests/ExcelReader.Tests/ParserFeatureTests.cs @@ -0,0 +1,139 @@ +using System.Globalization; +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Tests +{ + // Covers the parser additions: format-agnostic Parse(IExcelRowReader), configurable Culture, + // and enum/Guid column support. + public class ParserFeatureTests + { + private enum Status + { + Unknown = 0, + Active = 1, + Closed = 2, + } + + private sealed class MoneyRow + { + public string? Name { get; set; } + public decimal Amount { get; set; } + } + + private sealed class TypedRow + { + public Status Status { get; set; } + public Status? OptionalStatus { get; set; } + public Guid Id { get; set; } + public Guid? OptionalId { get; set; } + } + + // --- #1 Parse(IExcelRowReader) --- + + [Fact] + public async Task ParseAcceptsAutoDetectedReader() + { + await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Alice", 12.5]); + // Excel.Open returns the format-agnostic IExcelRowReader. + using IExcelRowReader reader = Excel.Open(ms); + + MoneyRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal("Alice", row.Name); + Assert.Equal(12.5m, row.Amount); + } + + [Fact] + public async Task ParseAsyncAcceptsAutoDetectedReader() + { + await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Bob", 7.0]); + await using IExcelRowReader reader = await Excel.OpenAsync(ms, ct: TestContext.Current.CancellationToken); + + var rows = new List(); + await foreach (MoneyRow row in new ExcelParser().ParseAsync(reader, TestContext.Current.CancellationToken)) + { + rows.Add(row); + } + + MoneyRow only = Assert.Single(rows); + Assert.Equal("Bob", only.Name); + Assert.Equal(7.0m, only.Amount); + } + + // --- #2 Culture --- + + [Fact] + public async Task PtBrCultureParsesCommaDecimalAndThousandsSeparator() + { + // Brazilian text cell: "1.234,56" → 1234.56. Inline strings keep the text verbatim. + await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "1.234,56"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + var config = new ExcelParserConfig { Culture = CultureInfo.GetCultureInfo("pt-BR") }; + + MoneyRow row = new ExcelParser(config).Parse(reader).Single(); + + Assert.Equal(1234.56m, row.Amount); + } + + [Fact] + public async Task InvariantCultureRejectsCommaDecimal() + { + // With the default invariant culture, "1.234,56" is not a valid decimal → keeps default. + await using var ms = await TypedWorkbook.BuildAsync(["Name", "Amount"], ["Conta", "1.234,56"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + MoneyRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(0m, row.Amount); + } + + // --- #4 Enum + Guid --- + + [Fact] + public async Task EnumColumnsParseByNameAndNumber() + { + var id = Guid.NewGuid(); + var optId = Guid.NewGuid(); + await using var ms = await TypedWorkbook.BuildAsync( + ["Status", "OptionalStatus", "Id", "OptionalId"], + ["Active", 2, id.ToString(), optId.ToString()]); // name, numeric, guid, guid + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + TypedRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(Status.Active, row.Status); // by name + Assert.Equal(Status.Closed, row.OptionalStatus); // by underlying number + Assert.Equal(id, row.Id); + Assert.Equal(optId, row.OptionalId); + } + + [Fact] + public async Task EnumIsCaseInsensitiveAndInvalidKeepsDefault() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Status", "OptionalStatus"], + ["active", "garbage"]); // lowercase name; unparseable + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + TypedRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(Status.Active, row.Status); // case-insensitive + Assert.Null(row.OptionalStatus); // invalid → left null + } + + [Fact] + public async Task InvalidGuidKeepsDefault() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Id", "OptionalId"], + ["not-a-guid", "also-bad"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + TypedRow row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(Guid.Empty, row.Id); + Assert.Null(row.OptionalId); + } + } +} diff --git a/tests/ExcelReader.Tests/RequiredTests.cs b/tests/ExcelReader.Tests/RequiredTests.cs new file mode 100644 index 00000000..4e760d93 --- /dev/null +++ b/tests/ExcelReader.Tests/RequiredTests.cs @@ -0,0 +1,163 @@ +using ExcelReader.Core.Parser; +using ExcelReader.Core.Reader; + +namespace ExcelReader.Tests +{ + // Covers [ExcelRequired]: the column must be present in the header row, validated as soon as the + // header is read. Enforces column presence only, not per-row values. + public class RequiredTests + { + private sealed class Row + { + [ExcelRequired] + public int Id { get; set; } + + [ExcelRequired] + [ExcelColumn("FullName")] + public required string Name { get; set; } + + public string? Note { get; set; } // optional + } + + private sealed class ValueRow + { + [ExcelRequired] + public string? Code { get; set; } + } + + private sealed class PresenceOnlyRow + { + [ExcelRequired(AllowEmpty = true)] + public string? Code { get; set; } + } + + private sealed class TwoColRow + { + public string? Note { get; set; } + + [ExcelRequired] + public required string Code { get; set; } + } + + private sealed class CustomValue + { + public string? Raw { get; set; } + } + + private sealed class UnsupportedRequiredRow + { + [ExcelRequired] + public CustomValue Thing { get; set; } = new(); + } + + [Fact] + public async Task RequiredColumnsPresentParseNormally() + { + await using var ms = await TypedWorkbook.BuildAsync( + ["Id", "FullName", "Note"], + [1, "Alice", "hi"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + Row row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(1, row.Id); + Assert.Equal("Alice", row.Name); + Assert.Equal("hi", row.Note); + } + + [Fact] + public async Task OptionalColumnMayBeAbsent() + { + // Note is not required; its absence must not throw. + await using var ms = await TypedWorkbook.BuildAsync(["Id", "FullName"], [2, "Bob"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + Row row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal(2, row.Id); + Assert.Equal("Bob", row.Name); + Assert.Null(row.Note); + } + + [Fact] + public async Task MissingRequiredColumnThrowsListingAll() + { + // Neither required header is present (Name's alias is "FullName", not "Name"). + await using var ms = await TypedWorkbook.BuildAsync(["Note"], ["x"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvalidOperationException ex = Assert.Throws( + () => new ExcelParser().Parse(reader).ToList()); + + Assert.Contains("Id", ex.Message, StringComparison.Ordinal); + Assert.Contains("FullName", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task RequiredColumnMatchedByAliasSucceeds() + { + await using var ms = await TypedWorkbook.BuildAsync(["Id", "FullName"], [3, "Carol"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + Row row = new ExcelParser().Parse(reader).Single(); + + Assert.Equal("Carol", row.Name); + } + + [Fact] + public async Task EmptyValueInRequiredColumnThrowsWithRowNumber() + { + // Header row 1, data rows 2 and 3; row 3's Code cell is blank. + await using var ms = await TypedWorkbook.BuildMultiSheetAsync( + ("S1", [["Code"], ["A1"], [null]])); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + var enumerator = new ExcelParser().Parse(reader).GetEnumerator(); + Assert.True(enumerator.MoveNext()); // row 2 ok + Assert.Equal("A1", enumerator.Current.Code); + + InvalidOperationException ex = Assert.Throws(() => enumerator.MoveNext()); + Assert.Contains("Code", ex.Message, StringComparison.Ordinal); + Assert.Contains("row 3", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AbsentCellInRequiredColumnThrows() + { + // Two columns in the header; the second data row omits the Code cell entirely (short row). + await using var ms = await TypedWorkbook.BuildMultiSheetAsync( + ("S1", [["Note", "Code"], ["n1", "c1"], ["n2"]])); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvalidOperationException ex = Assert.Throws( + () => new ExcelParser().Parse(reader).ToList()); + Assert.Contains("Code", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AllowEmptyRequiresColumnButPermitsBlankValues() + { + await using var ms = await TypedWorkbook.BuildMultiSheetAsync( + ("S1", [["Code"], ["A1"], [null]])); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + List rows = new ExcelParser().Parse(reader).ToList(); + + Assert.Equal(2, rows.Count); + Assert.Equal("A1", rows[0].Code); + Assert.Null(rows[1].Code); // blank allowed + } + + [Fact] + public async Task RequiredPropertyWithNoParserThrows() + { + await using var ms = await TypedWorkbook.BuildAsync(["Thing"], ["v"]); + await using var reader = await Excel.FromAsync(ms, ct: TestContext.Current.CancellationToken); + + InvalidOperationException ex = Assert.Throws( + () => new ExcelParser().Parse(reader).ToList()); + + Assert.Contains("ExcelRequired", ex.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/ExcelReader.Tests/SampleTest.cs b/tests/ExcelReader.Tests/SampleTest.cs index b404a722..63181a60 100644 --- a/tests/ExcelReader.Tests/SampleTest.cs +++ b/tests/ExcelReader.Tests/SampleTest.cs @@ -106,10 +106,7 @@ public void DecodesXmlEntitiesInSharedStrings() using var reader = Excel.From(ms); using var enumerator = reader.GetEnumerator(); - if (!enumerator.MoveNext()) - { - Assert.Fail("Expected at least one row"); - } + Assert.True(enumerator.MoveNext(), "Expected at least one row"); var row = enumerator.Current; Assert.Equal("a & b A", row[0].GetString()); } diff --git a/tests/ExcelReader.Tests/XlsWriterTests.cs b/tests/ExcelReader.Tests/XlsWriterTests.cs index 1a6d8921..f7def9f0 100644 --- a/tests/ExcelReader.Tests/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/XlsWriterTests.cs @@ -201,6 +201,50 @@ public async Task EmptyWorkbookThrows() await Assert.ThrowsAsync(async () => await wb.EndAsync(TestContext.Current.CancellationToken)); } + [Fact] + public async Task LargeWorkbookForcesDifatSectors() + { + // The OLE container only allocates DIFAT sectors once the FAT exceeds the 109 entries that + // fit in the header — that needs a workbook stream past ~7.1 MB (>~13,900 512-byte sectors). + // 64000 rows x 8 NUMBER cells (18 bytes each) ≈ 9.2 MB, comfortably over the threshold. + CancellationToken ct = TestContext.Current.CancellationToken; + const int rows = 64000; + const int cols = 8; + byte[] bytes = await WriteAsync(wb => + { + var s = wb.AddSheet("Big"); + s.Start(); + for (int i = 0; i < rows; i++) + { + using var r = s.StartRow(); + r.Write(i); + for (int c = 1; c < cols; c++) + { + r.Write(i + (c * 0.5)); + } + } + s.End(); + }, ct: ct); + + Assert.True(bytes.Length > 7 * 1024 * 1024, "workbook must exceed the DIFAT threshold"); + + using var reader = Excel.FromXls(new MemoryStream(bytes)); + using var e = reader.GetEnumerator(); + int read = 0; + while (e.MoveNext()) + { + if (read == 0 || read == rows - 1) + { + Assert.True(e.Current[0].TryParse(Inv, out int first)); + Assert.Equal(read, first); + Assert.True(e.Current[cols - 1].TryParse(Inv, out double last)); + Assert.Equal(read + ((cols - 1) * 0.5), last); + } + read++; + } + Assert.Equal(rows, read); + } + [Fact] public async Task RowOverflowAutoSplitsIntoNewSheet() {