Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ while (await rows.MoveNextAsync())

## Parse typed rows

Use `[ExcelColumn]` when the spreadsheet header does not match the property name.
`ExcelParser<T>` maps worksheet columns to the public settable properties of `T`. Columns match on the property name, or on `[ExcelColumn("header")]` aliases — repeat the attribute to accept several headers. The first row is the header by default.

```csharp
using ExcelReader.Core.Parser;
Expand All @@ -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<T>`. 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<ChangeRow>().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<ChangeRow>(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<T>` 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<decimal>
{
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
Expand Down
3 changes: 3 additions & 0 deletions src/ExcelReader.Core/ExcelReader.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@
<ItemGroup>
<InternalsVisibleTo Include="ExcelReader.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FastEnum" Version="2.0.6" />
</ItemGroup>

</Project>
16 changes: 16 additions & 0 deletions src/ExcelReader.Core/Parser/ExcelConverterAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace ExcelReader.Core.Parser
{
// Binds a custom IExcelCellConverter<TProperty> 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;
}
}
}
18 changes: 17 additions & 1 deletion src/ExcelReader.Core/Parser/ExcelParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace ExcelReader.Core.Parser
{
public sealed class ExcelParser<T> where T : new()
public sealed class ExcelParser<T>
{
private readonly ExcelParserConfig _config;

Expand Down Expand Up @@ -41,6 +41,16 @@ public XlsExcelEnumerable<T> Parse(XlsReader reader)
return new ExcelEnumerable<T, XlsbReader, XlsbReader.Enumerator>(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<T, IExcelRowReader, IExcelRowEnumerator> Parse(IExcelRowReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
return new ExcelEnumerable<T, IExcelRowReader, IExcelRowEnumerator>(reader, _config);
}

public ExcelEnumerable<T> ParseAsync(XlsxReader reader, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(reader);
Expand All @@ -58,5 +68,11 @@ public XlsExcelEnumerable<T> ParseAsync(XlsReader reader, CancellationToken ct =
ArgumentNullException.ThrowIfNull(reader);
return new ExcelEnumerable<T, XlsbReader, XlsbReader.Enumerator>(reader, _config, ct);
}

public ExcelEnumerable<T, IExcelRowReader, IExcelRowEnumerator> ParseAsync(IExcelRowReader reader, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(reader);
return new ExcelEnumerable<T, IExcelRowReader, IExcelRowEnumerator>(reader, _config, ct);
}
}
}
8 changes: 8 additions & 0 deletions src/ExcelReader.Core/Parser/ExcelParserConfig.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions src/ExcelReader.Core/Parser/ExcelRequiredAttribute.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
39 changes: 39 additions & 0 deletions src/ExcelReader.Core/Parser/HeaderNormalization.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
17 changes: 17 additions & 0 deletions src/ExcelReader.Core/Parser/IExcelCellConverter.cs
Original file line number Diff line number Diff line change
@@ -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<decimal?> 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<T>
{
bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out T value);
}
}
Loading
Loading