Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Removed from v1 vs. Ch. 16: `IRetryPolicy`, `IExceptionClassifier`, `IAuthProvid
| D11 | Retry/Skip | Retry (Polly) wraps the batch read; a read failure is not "skippable" (no cursor to advance) → becomes Abort; a projection/write failure is skippable (cursor already known) |
| D12 | Map in the builder | `Map` is not a step that changes the builder's type; mapping is expressed by `From(source, map)`, keeping `ReportBuilder<TRow>` single-generic and compatible with `AddReport<TRow>(Action<...>)` |
| D13 | SQL keyset source | `Source.Sql(connString, sql).Keyset(key, pageSize)`; the query carries `@cursor` (`(@cursor IS NULL OR Id > @cursor)`) and `ORDER BY`; connection per page; cursor = last key as `string?`; binds only the parameters the query references; connection-by-name is post-MVP |
| D14 | In-memory XLSX | The XLSX writer uses ClosedXML, which materializes the whole sheet in memory before saving — a conscious exception to "constant memory" (rule 8). Acceptable for v1 sizes; streaming OpenXML is post-MVP. CSV stays truly streaming |
| D14 | Streaming XLSX (superseded 2026-07-30) | **Originally:** the XLSX writer used ClosedXML, which materializes the whole workbook in memory — a conscious exception to "constant memory" (rule 8), accepted for v1 sizes. **Now resolved:** both the MIT single-sheet writer and the Pro multi-sheet workbook writer are rewritten on `DocumentFormat.OpenXml`'s SAX `OpenXmlWriter`, streaming each worksheet's XML straight to a per-sheet temp file (0600 on Unix) and hand-assembling the `.xlsx` with `System.IO.Compression.ZipArchive` in Create mode written directly to the pipeline's write-only output stream — bypassing `System.IO.Packaging`, whose `ZipPackage` (Update mode) buffers every part in RAM. Measured live memory is flat (~1.5 MB) writing 100k→2.4M rows while the output grows to 60+ MB; a regression test enforces it. Strings are inline (no shared-string table). The only behavioural change is the dropped column auto-fit (`AdjustToContents` is O(rows×cols) and can't stream). ClosedXML is removed from both writer packages. CSV was already truly streaming |
| D15 | All-or-nothing S3 | `Destination.S3(bucket, keyTemplate)` uses `PutObject` (atomic per object): a failure leaves no partial object. Client from DI (`IAmazonS3`) or AWS defaults. Multipart for large objects is post-MVP |
| D16 | Format entry point | Each format package exposes a `static class Format` with a `Csv()`/`Xlsx()` method. To use two formats together (the spec does `Format.Csv(...).Format.Xlsx(...)`), the consumer uses `using static ...Csv.Format;` + `using static ...Xlsx.Format;` and calls `Csv(...)`/`Xlsx(...)` — avoiding the `Format` name collision between the two assemblies |
| D17 | Assertion lib | Tests use **Shouldly** (MIT). FluentAssertions left because v8 went commercial-license (Xceed); being stuck on 7.x would block updates. Dependabot keeps the test-tooling group up to date without FA |
Expand Down
8 changes: 5 additions & 3 deletions benchmarks/NeoReports.Benchmarks/ReportMemoryBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ namespace NeoReports.Benchmarks;
/// <see cref="RowCount"/> rows. With <c>MemoryDiagnoser</c>, the key signal is that the
/// <b>Allocated</b> column scales <i>linearly</i> with <see cref="RowCount"/> (constant
/// allocation per row, no super-linear growth) and that the working set does not grow with the
/// total — i.e. nothing buffers the whole report. CSV is fully streaming; XLSX is included for
/// contrast (ClosedXML builds the workbook in memory by design — see ADR D14).
/// total — i.e. nothing buffers the whole report. Both CSV and XLSX are fully streaming: the XLSX
/// writer streams each worksheet to a temp file and assembles the package with a hand-written
/// <c>ZipArchive</c>, so live memory stays flat regardless of row count (it no longer builds the
/// workbook in memory — the ClosedXML approach noted in ADR D14 was replaced).
/// </summary>
[MemoryDiagnoser]
public class ReportMemoryBenchmark
Expand Down Expand Up @@ -61,7 +63,7 @@ public async Task<long> Csv()
return result.Stats.RecordsWritten;
}

[Benchmark(Description = "XLSX (ClosedXML, in-memory)")]
[Benchmark(Description = "XLSX (streaming)")]
public async Task<long> Xlsx()
{
var result = await RunAsync(_xlsxReport).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<Description>XLSX (Excel) format writer for NeoReports, backed by ClosedXML.</Description>
<Description>Streaming XLSX (Excel) format writer for NeoReports.</Description>
<PackageTags>reports;reporting;xlsx;excel;data-export</PackageTags>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="NeoReports.Xlsx.Pro" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\NeoReports.Abstractions\NeoReports.Abstractions.csproj" />
<ProjectReference Include="..\..\NeoReports.Core\NeoReports.Core.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="ClosedXML" />
<PackageReference Include="DocumentFormat.OpenXml" />
</ItemGroup>

</Project>
122 changes: 76 additions & 46 deletions src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs
Original file line number Diff line number Diff line change
@@ -1,72 +1,102 @@
using ClosedXML.Excel;
using NeoReports.Abstractions;
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Spreadsheet;

namespace NeoReports.Formats.Xlsx;

/// <summary>
/// Writes a single projected value into a ClosedXML cell with the right native Excel type (number,
/// date, boolean, ...) and the column's number/date format. Public so packages built on the XLSX
/// format (e.g. a multi-sheet workbook writer) reuse the exact same cell semantics without
/// duplicating them.
/// Builds a single streaming XLSX <see cref="Cell"/> with the right native Excel type (number, date,
/// boolean, inline string) and the precomputed per-column style index. Shared by the single-sheet and
/// multi-sheet workbook writers so their cell semantics stay identical. Strings are emitted as INLINE
/// strings (never shared strings), which keeps memory constant — a shared-string table would buffer
/// every distinct value for the life of the write.
/// </summary>
public static class XlsxCells
internal static class XlsxCells
{
/// <summary>Sets a cell's value (native-typed) and applies the column's format.</summary>
/// <param name="cell">The target ClosedXML cell.</param>
/// <param name="value">The projected value, or <c>null</c> to clear the cell.</param>
/// <param name="column">The column metadata (drives the applied format).</param>
public static void SetCell(IXLCell cell, object? value, ReportColumn column)
/// <summary>
/// Builds the cell for a projected value, or returns <c>null</c> when the value is <c>null</c> (the
/// caller omits the cell so it reads back as empty). <paramref name="numberStyleIndex"/> styles
/// numeric cells and <paramref name="dateStyleIndex"/> styles date cells; both come from
/// <see cref="XlsxStyleTable"/>.
/// </summary>
public static Cell? BuildCell(object? value, string reference, int numberStyleIndex, int dateStyleIndex)
{
ArgumentNullException.ThrowIfNull(cell);
ArgumentNullException.ThrowIfNull(column);

if (value is null)
{
cell.Clear(XLClearOptions.Contents);
return;
}
return null;

switch (value)

Check warning on line 27 in src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'switch' expression

See more on https://sonarcloud.io/project/issues?id=lugarini_NeoReports&issues=AZ-zD1KHJ13m9rvSUGFP&open=AZ-zD1KHJ13m9rvSUGFP&pullRequest=226
{
case bool b:
cell.Value = b;
break;
case byte or sbyte or short or ushort or int or uint or long or ulong
or float or double or decimal:
cell.Value = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
ApplyFormat(cell, column);
break;
return new Cell
{
CellReference = reference,
DataType = CellValues.Boolean,
CellValue = new CellValue(b ? "1" : "0"),
};
case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal:
return NumberCell(Convert.ToDouble(value, CultureInfo.InvariantCulture), reference, numberStyleIndex);
case DateTime dt:
cell.Value = dt;
ApplyDateFormat(cell, column);
break;
return DateCell(dt, reference, dateStyleIndex);
case DateTimeOffset dto:
cell.Value = dto.DateTime;
ApplyDateFormat(cell, column);
break;
return DateCell(dto.DateTime, reference, dateStyleIndex);
case DateOnly d:
cell.Value = d.ToDateTime(TimeOnly.MinValue);
ApplyDateFormat(cell, column);
break;
return DateCell(d.ToDateTime(TimeOnly.MinValue), reference, dateStyleIndex);
case Guid g:
cell.Value = g.ToString();
break;
return InlineStringCell(g.ToString(), reference);
default:
cell.Value = value.ToString();
break;
return InlineStringCell(value.ToString() ?? string.Empty, reference);
}
}

private static void ApplyFormat(IXLCell cell, ReportColumn column)
/// <summary>Builds a bold header cell holding an inline string.</summary>
public static Cell HeaderCell(string text, string reference) => new()
{
if (!string.IsNullOrEmpty(column.Format))
cell.Style.NumberFormat.Format = ExcelFormat.FromNetFormat(column.Format!, column);
CellReference = reference,
StyleIndex = XlsxStyleTable.HeaderStyleIndex,
DataType = CellValues.InlineString,
InlineString = new InlineString(new Text(text) { Space = SpaceProcessingModeValues.Preserve }),

Check warning on line 57 in src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Review this call, which partially matches an overload without 'params'. The partial match is 'InlineString.InlineString(IEnumerable<OpenXmlElement> childElements)'.

Check warning on line 57 in src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Review this call, which partially matches an overload without 'params'. The partial match is 'InlineString.InlineString(IEnumerable<OpenXmlElement> childElements)'.

See more on https://sonarcloud.io/project/issues?id=lugarini_NeoReports&issues=AZ-zD1KHJ13m9rvSUGFN&open=AZ-zD1KHJ13m9rvSUGFN&pullRequest=226
};

/// <summary>Converts a 0-based column index to Excel column letters (0 → A, 25 → Z, 26 → AA).</summary>
public static string ColumnLetter(int columnIndex)
{
Span<char> buffer = stackalloc char[8];
var position = buffer.Length;
var n = columnIndex;
do
{
buffer[--position] = (char)('A' + (n % 26));
n = (n / 26) - 1;
}
while (n >= 0);

return new string(buffer[position..]);
}

private static void ApplyDateFormat(IXLCell cell, ReportColumn column)
private static Cell NumberCell(double value, string reference, int styleIndex)
{
cell.Style.DateFormat.Format = string.IsNullOrEmpty(column.Format)
? "yyyy-mm-dd"
: ExcelFormat.FromNetDateFormat(column.Format!);
var cell = new Cell
{
CellReference = reference,
CellValue = new CellValue(value.ToString(CultureInfo.InvariantCulture)),
};
if (styleIndex != XlsxStyleTable.DefaultStyleIndex)
cell.StyleIndex = (uint)styleIndex;
return cell;
}

// Dates are stored as their numeric OADate serial (no data type) styled with a date number-format.
private static Cell DateCell(DateTime value, string reference, int styleIndex) => new()
{
CellReference = reference,
StyleIndex = (uint)styleIndex,
CellValue = new CellValue(value.ToOADate().ToString(CultureInfo.InvariantCulture)),
};

private static Cell InlineStringCell(string text, string reference) => new()
{
CellReference = reference,
DataType = CellValues.InlineString,
InlineString = new InlineString(new Text(text) { Space = SpaceProcessingModeValues.Preserve }),

Check warning on line 100 in src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Review this call, which partially matches an overload without 'params'. The partial match is 'InlineString.InlineString(IEnumerable<OpenXmlElement> childElements)'.

Check warning on line 100 in src/Formats/NeoReports.Formats.Xlsx/XlsxCells.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Review this call, which partially matches an overload without 'params'. The partial match is 'InlineString.InlineString(IEnumerable<OpenXmlElement> childElements)'.

See more on https://sonarcloud.io/project/issues?id=lugarini_NeoReports&issues=AZ-zD1KHJ13m9rvSUGFO&open=AZ-zD1KHJ13m9rvSUGFO&pullRequest=226
};
}
Loading