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
10 changes: 7 additions & 3 deletions .github/workflows/nuget-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ jobs:
with:
nuget-projects: >-
SW.ExportToExcel/SW.ExportToExcel.csproj
major-version: '8'
major-version: '10'
minor-version: '0'
dotnet-version: '8.0.x'
run-tests: false
# Both SDKs: the package and its tests target net10.0 and net8.0, and the
# net8.0 tests need the 8.0 runtime on the runner.
dotnet-version: |
8.0.x
10.0.x
run-tests: true
test-projects: '**/*UnitTests/*.csproj'
secrets:
nuget-api-key: ${{ secrets.SWNUGETKEY }}
Expand Down
148 changes: 133 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,149 @@

[![GitHub Actions](https://github.com/simplify9/SW-ExportToExcel/actions/workflows/nuget-publish.yml/badge.svg)](https://github.com/simplify9/SW-ExportToExcel/actions/workflows/nuget-publish.yml)
[![NuGet](https://img.shields.io/nuget/v/SimplyWorks.ExportToExcel?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE)

| **Package** | **Version** |
| :----------------:|:----------------------:|
| SimplyWorks.ExportToExcel | [![NuGet](https://img.shields.io/nuget/v/SimplyWorks.ExportToExcel?style=for-the-badge)](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/) |
# SimplyWorks.ExportToExcel

Extension methods that write any `IEnumerable<T>` (or `IAsyncEnumerable<T>`) to an `.xlsx` file.

- Rows are streamed straight into the file, so memory stays flat no matter how many rows you export.
- Targets .NET 10 and .NET 8.

```bash
dotnet add package SimplyWorks.ExportToExcel
```

## Quick start

```csharp
using SW.ExportToExcel;

using var stream = new MemoryStream();
await orders.WriteExcelAsync(stream);
```

This writes every readable property of a supported type, in declaration order, with the property name as the header. To choose the columns, rename them or put them in a different order, pass a map from property name to header:

```csharp
await orders.WriteExcelAsync(stream, new Dictionary<string, string>
{
["Total"] = "Total (€)",
["Customer"] = "Customer",
["PlacedOn"] = "Date",
});
```

Columns come out in the order you list them. Names that don't match a property are skipped.

### Computed columns

```csharp
await orders.WriteExcelAsync(stream, new[]
{
new ExcelColumn<Order>("Customer", o => o.Customer.Name),
new ExcelColumn<Order>("City", o => o.Customer.Address.City),
new ExcelColumn<Order>("Total incl. VAT", o => o.Total * 1.2m) { Width = 18 },
});
```

### Dictionary and dynamic rows

Rows of type `IDictionary<string, object>` (including `ExpandoObject`) or `IDictionary<string, string>` are exported by key.

- **Headers:** the keys found in the first rows that are read ahead (100 by default; see `AutoFitSampleRows` below).
- **Later rows:** a key a row doesn't have leaves its cell empty.
- **Choosing columns:** pass a column map to set which keys appear and in what order.

```csharp
var rows = new List<Dictionary<string, object>>
{
new() { ["Sku"] = "A-1", ["Qty"] = 3 },
new() { ["Sku"] = "B-2", ["Qty"] = 1, ["Note"] = "fragile" },
};
await rows.WriteExcelAsync(stream);
```

### Streaming from a database

Pass an `IAsyncEnumerable<T>`, such as EF Core's `AsAsyncEnumerable()`, and rows are written as they arrive, without loading the whole result first:

```csharp
await db.Orders.Where(o => o.Year == 2026).AsAsyncEnumerable()
.WriteExcelAsync(stream, cancellationToken: ct);
```

## Cell types

| Value | Excel cell |
| --- | --- |
| `int`, `long`, `decimal`, `double` and the other numeric types | number |
| `bool` | `TRUE` / `FALSE` |
| `DateTime`, `DateTimeOffset` | date and time (a `DateTimeOffset` keeps its own clock time) |
| `DateOnly` | date |
| `string`, `char`, `Guid`, enums (by name), `TimeSpan`, `TimeOnly` | text |
| `null`, empty string, dates before year 100 | empty cell |
| `NaN`, `Infinity` | the text `NaN` / `Infinity` |

Properties of any other type, such as nested objects and collections, are skipped. Computed columns can return anything; values that aren't listed above are written as text.

Text is cleaned so that it always produces a file Excel can open: characters that XML can't carry (control characters, unpaired surrogates) are removed, and text over Excel's 32,767-character cell limit is truncated.

## Options

```csharp
await orders.WriteExcelAsync(stream, new ExcelExportOptions
{
SheetName = "Orders", // invalid characters become '_', truncated to 31
FreezeHeaderRow = true, // default true
BoldHeader = true, // default true
AutoFilter = true, // filter drop-downs on the header, default false
AutoFitColumns = true, // size columns from the header and the first rows, default true
AutoFitSampleRows = 100, // rows read ahead for sizing and dictionary keys
MaxColumnWidth = 60,
DateTimeFormat = "yyyy-mm-dd hh:mm", // default: Excel's built-in short date and time
DateFormat = "yyyy-mm-dd", // for DateOnly; default: Excel's built-in short date
});
```

## Writing to a web response

The file is written synchronously. ASP.NET Core rejects synchronous writes to a response body, so write to a `MemoryStream` (or a file) first, then return that:

```csharp
using var stream = new MemoryStream();
await rows.WriteExcelAsync(stream);
return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "export.xlsx");
```

## The original API

`WriteExcel(...)`, `ExportToExcel(...)` and `GenerateExcel(...)` are unchanged and produce the same sheets as before:

- Columns always follow the class's property declaration order. A column map only selects and renames columns; it doesn't reorder them.
- Booleans are written as the text `true` / `false`.
- Only `string`, `bool`, `DateTime` and numeric properties are exported.
- Every cell is written, including empty ones.

## Introduction
*ExportToExcel* is a library that provides extensions to [IEnumerable Interface](https://docs.microsoft.com/en-us/dotnet/api/system.collections.ienumerable?view=netcore-3.1) extensions.
The original methods run on the same streaming writer, so they get the same speed and memory gains and the same fixes:

## Getting Started
*ExportToExcel* is available as a package on [NuGet](https://www.nuget.org/packages/SimplyWorks.ExportToExcel/).
- Characters XML can't carry, `NaN` and `Infinity`, over-long text, and types with nothing to export no longer break the export or the file.
- The header row is now actually frozen; in earlier versions the freeze setting was misplaced and Excel ignored it.
- The first column is no longer forced to a width of 4.
- Dictionary rows are matched by key instead of by position.
- An empty dictionary list gives an empty sheet instead of throwing.

To use *ExportToExcel*, you will require the [`Documentformat.OpenXml`](https://www.nuget.org/packages/DocumentFormat.OpenXml/) package.
To get ordered columns and typed cells, switch to `WriteExcelAsync`. The two differ only as described above.

## Functions Available
1. *ExportToExcel*
## Limits

ExportToExcel has 3 overloads. You can pass it an enumerable of data, where the column names are generated from TEntity's properties. One where you can pass both an enumerable of data and an enumerable of string, where the column names will be taken from this enumerable. And finally, one where you can pass it a dictionary that will be used to generate the column names.
An Excel sheet holds at most 1,048,576 rows and 16,384 columns. Larger exports are still written, but Excel won't open them; other readers may.

2. *WriteExcel*
## Upgrading from 8.x / 2.x

WriteExcel has 6 overloads, taking in column names from a dictionary, as well as some data in an IEnumberable array. It also takes in a file stream to start writing onto that very file with the data it's collected.
- **Frameworks:** the package now targets `net10.0` and `net8.0`. Projects on older frameworks can stay on 2.0.5 (`netstandard2.0`).
- **OpenXml 3:** the library no longer uses `DocumentFormat.OpenXml` itself, but still depends on it, now at 3.x instead of 2.13, so projects that get it through this package keep compiling. If you use OpenXml's API directly, check its [3.0 breaking changes](https://github.com/dotnet/Open-XML-SDK/blob/main/CHANGELOG.md).
- **Output changes:** the original methods' output changes only where it was broken before, as listed under [The original API](#the-original-api).

## Getting support 👷
If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/ExportToExcel/issues). We'll get back to you promptly!

If you encounter any bugs, don't hesitate to submit an [issue](https://github.com/simplify9/SW-ExportToExcel/issues). We'll get back to you promptly!
200 changes: 200 additions & 0 deletions SW.ExportToExcel.UnitTests/ExcelExportTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml.Spreadsheet;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using XlsxWriter = SW.ExportToExcel.Internal.XlsxWriter;

namespace SW.ExportToExcel.UnitTests
{
[TestClass]
public class ExcelExportTests
{
private static readonly DateTime When = new DateTime(2026, 10, 12, 9, 30, 0);

private static async Task<Sheet> Write(Func<Stream, Task> write)
{
using var stream = new MemoryStream();
await write(stream);
return new Sheet(stream.ToArray());
}

[TestMethod]
public async Task Columns_come_out_in_the_order_given()
{
var sheet = await Write(s => new[] { new Order { Id = 1, Customer = "c", Total = 9.5m } }.WriteExcelAsync(s,
new Dictionary<string, string> { ["Total"] = "Total €", ["Customer"] = "Customer", ["Nope"] = "x" }));

CollectionAssert.AreEqual(new[] { "Total €", "Customer" }, sheet.Row(1, 2));
CollectionAssert.AreEqual(new[] { "9.5", "c" }, sheet.Row(2, 2));
Assert.IsFalse(sheet.Has("C1"));
}

[TestMethod]
public async Task All_supported_properties_by_default_with_typed_cells()
{
var sheet = await Write(s => new[]
{
new Order { Id = 1, Customer = "c", Paid = true, Placed = When, Due = new DateOnly(2026, 11, 1), Status = DayOfWeek.Friday, Ref = Guid.Empty },
}.WriteExcelAsync(s));

CollectionAssert.AreEqual(new[] { "Id", "Customer", "Total", "Paid", "Placed", "Due", "Status", "Ref", "Note" }, sheet.Row(1, 9));
Assert.AreEqual("b", sheet.Type("D2"));
Assert.AreEqual("1", sheet.Text("D2"));
Assert.AreEqual(22u, sheet.NumberFormat("E2"));
Assert.AreEqual(14u, sheet.NumberFormat("F2"));
Assert.AreEqual(new DateTime(2026, 11, 1).ToOADate().ToString(System.Globalization.CultureInfo.InvariantCulture), sheet.Text("F2"));
Assert.AreEqual("Friday", sheet.Text("G2"));
Assert.AreEqual(Guid.Empty.ToString(), sheet.Text("H2"));
Assert.IsFalse(sheet.Has("J1"), "the List<string> property is not exportable");
}

[TestMethod]
public async Task Empty_values_leave_the_cell_out_and_later_cells_keep_their_column()
{
var sheet = await Write(s => new[] { new Order { Id = 3, Customer = null, Note = "n" } }.WriteExcelAsync(s));

Assert.IsFalse(sheet.Has("B2"));
Assert.IsFalse(sheet.Has("E2"));
Assert.AreEqual("n", sheet.Text("I2"));
Assert.AreEqual("3", sheet.Text("A2"));
}

[TestMethod]
public async Task Computed_columns()
{
var sheet = await Write(s => new[] { new Order { Id = 4, Customer = "acme", Total = 10m } }.WriteExcelAsync(s, new[]
{
new ExcelColumn<Order>("Upper", o => o.Customer.ToUpperInvariant()),
new ExcelColumn<Order>("With tax", o => o.Total * 1.2m) { Width = 30 }
}));

CollectionAssert.AreEqual(new[] { "ACME", "12.0" }, sheet.Row(2, 2));
var widths = sheet.Worksheet.Descendants<DocumentFormat.OpenXml.Spreadsheet.Column>().ToList();
Assert.AreEqual(30d, widths.Single(c => c.Min == 2).Width.Value);
}

[TestMethod]
public async Task Dictionary_and_expando_rows()
{
var rows = new List<Dictionary<string, object>>
{
new Dictionary<string, object> { ["a"] = 1, ["b"] = "x" },
new Dictionary<string, object> { ["b"] = "y", ["c"] = true }
};
var sheet = await Write(s => rows.WriteExcelAsync(s));
CollectionAssert.AreEqual(new[] { "a", "b", "c" }, sheet.Row(1, 3));
CollectionAssert.AreEqual(new[] { null, "y", "1" }, sheet.Row(3, 3));

dynamic expando = new ExpandoObject();
expando.Name = "dyn";
expando.Count = 2;
var dynamicSheet = await Write(s => new List<ExpandoObject> { expando }.WriteExcelAsync(s,
new Dictionary<string, string> { ["Count"] = "How many", ["Name"] = "Name" }));
CollectionAssert.AreEqual(new[] { "How many", "Name" }, dynamicSheet.Row(1, 2));
CollectionAssert.AreEqual(new[] { "2", "dyn" }, dynamicSheet.Row(2, 2));
}

[TestMethod]
public async Task Async_enumerable_source()
{
var sheet = await Write(s => Orders(5).WriteExcelAsync(s));

Assert.AreEqual(6, sheet.RowCount);
Assert.AreEqual("4", sheet.Text("A6"));
}

[TestMethod]
public async Task Options_shape_the_sheet()
{
var sheet = await Write(s => new[] { new Order { Placed = When } }.WriteExcelAsync(s, new ExcelExportOptions
{
SheetName = "Orders: 2026/10 [final] and a very long tail",
AutoFilter = true,
FreezeHeaderRow = false,
AutoFitColumns = false,
DateTimeFormat = "yyyy-mm-dd hh:mm"
}));

Assert.AreEqual("Orders_ 2026_10 _final_ and a v", sheet.Name);
Assert.AreEqual("A1:I2", sheet.Worksheet.Descendants<AutoFilter>().Single().Reference.Value);
Assert.AreEqual("_xlnm._FilterDatabase", sheet.Workbook.DefinedNames.Elements<DefinedName>().Single().Name.Value);
Assert.IsFalse(sheet.Worksheet.Descendants<Pane>().Any());
Assert.IsFalse(sheet.Worksheet.Descendants<Columns>().Any());
Assert.AreEqual(164u, sheet.NumberFormat("E2"));
}

[TestMethod]
public async Task Auto_fit_sizes_from_header_and_sample_and_respects_the_cap()
{
var sheet = await Write(s => new[] { new Order { Customer = new string('w', 500) } }.WriteExcelAsync(s));

var widths = sheet.Worksheet.Descendants<DocumentFormat.OpenXml.Spreadsheet.Column>().ToDictionary(c => c.Min.Value, c => c.Width.Value);
Assert.AreEqual(60d, widths[2]);
Assert.IsTrue(widths[1] >= 6 && widths[1] < 10);
Assert.AreNotEqual(0u, sheet.Style("A1"), "header carries the bold style");
}

[TestMethod]
public async Task Cancellation_is_honoured()
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();

await Assert.ThrowsAsync<OperationCanceledException>(() =>
Enumerable.Range(0, 10).Select(i => new Order { Id = i }).WriteExcelAsync(new MemoryStream(), cancellationToken: cancelled.Token));
await Assert.ThrowsAsync<OperationCanceledException>(() =>
Orders(10).WriteExcelAsync(new MemoryStream(), cancellationToken: cancelled.Token));
}

[TestMethod]
public async Task Large_exports_stream()
{
const int count = 100_000;
var sheet = await Write(s => Enumerable.Range(0, count)
.Select(i => new Order { Id = i, Customer = "c" + i, Total = i, Placed = When }).WriteExcelAsync(s));

Assert.AreEqual(count + 1, sheet.RowCount);
Assert.AreEqual("c99999", sheet.Text("B100001"));
}

[TestMethod]
[DataRow(0, "A")]
[DataRow(25, "Z")]
[DataRow(26, "AA")]
[DataRow(676, "ZA")]
[DataRow(701, "ZZ")]
[DataRow(702, "AAA")]
[DataRow(16383, "XFD")]
public void Column_names(int index, string expected) => Assert.AreEqual(expected, XlsxWriter.ColumnName(index));

private static async IAsyncEnumerable<Order> Orders(int count, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
for (var i = 0; i < count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return new Order { Id = i };
}
}

private class Order
{
public int Id { get; set; }
public string Customer { get; set; }
public decimal Total { get; set; }
public bool Paid { get; set; }
public DateTime? Placed { get; set; }
public DateOnly? Due { get; set; }
public DayOfWeek? Status { get; set; }
public Guid? Ref { get; set; }
public string Note { get; set; }
public List<string> Tags { get; set; } = new List<string>();
}
}
}
Loading
Loading