Skip to content

Commit 9fa21f5

Browse files
committed
feat(core): B1.2 — sectioned-output hook (one file, many sections)
The OSS/MIT enabling piece for the Pro workbook. A single output can now carry several sections (one file with many sections — e.g. an XLSX workbook with a worksheet per section), each with its own filters/columns, all projected in one source pass. The Pro ClosedXML writer + XlsxWorkbook(...) API (B1.3) implement the new Core contract. - New Core contracts (in Core, not the frozen Abstractions, like the artifact store): IReportSectionedWriter, ISectionedWriterFactory, SectionedWriterContext, ReportSection. - Builder: ToSections(SectionedOutputSpec, s => s.Section("name", v => v.Where(...).Column(...))) via a SectionBuilder<T> reusing the B1.1 OutputView<T>. CompiledReport gains SectionedOutputs (spec + per-section name/schema). - Pipeline: TypedBatchReader also projects each section (single pass); ProjectedBatch carries per-sectioned-output, per-section rows; WrittenCount counts a source row once across outputs and sections. The runner creates one IReportSectionedWriter per sectioned output, writes each section per batch, finalizes one file, uploads/retains it (unified with regular outputs via an IFinishedFile interface). - Additive: reports without sectioned outputs are byte-identical (all existing tests green). Tests: 55 green Core (+1) — a fake sectioned writer proves single-read, per-section projection with different columns into one file. Jobs (16) and AspNetCore (10) unaffected. PLAN B1.2 done; B1.3 is the commercial NeoReports.Xlsx.Pro package.
1 parent 6695b0b commit 9fa21f5

10 files changed

Lines changed: 496 additions & 52 deletions

File tree

PLAN.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,17 @@ boundary) are still open in that doc and must be settled before B1.2.
134134
single pass and writes one file per view. Default single-output path stays byte-identical.
135135
✅ 54 green Core tests (+3); Jobs (16) and AspNetCore (10) unaffected. The Pro workbook writer
136136
(views → sheets in one file) is B1.2.
137-
- [ ] **B1.2 — `NeoReports.Xlsx.Pro` package (commercial).** Fluent `XlsxWorkbook(...)` API + ClosedXML
138-
workbook writer (one named sheet per section). Placeholder commercial LICENSE/metadata. Golden-file
139-
test.
140-
- [ ] **B1.3 — Packaging & CI.** Pro package builds/packs but is excluded from the OSS NuGet release.
141-
- [ ] **B1.4 — Sample** `06-multi-sheet-xlsx` (typed: Approved/Rejected sheets).
142-
- [ ] **B1.5 — Dynamic config** support for `xlsx-workbook` (optional, after the typed API settles).
137+
- [x] **B1.2 — OSS sectioned-output hook (MIT).** A single output can carry several sections (one file,
138+
many sections — e.g. a workbook) via `ToSections(spec, s => s.Section("name", v => ...))`, each with
139+
its own filter/columns, all projected in one pass. New Core contracts `IReportSectionedWriter` /
140+
`ISectionedWriterFactory` (in Core, not the frozen Abstractions). ✅ 55 green Core tests (+1); Jobs
141+
(16) and AspNetCore (10) unaffected; default path still byte-identical.
142+
- [ ] **B1.3 — `NeoReports.Xlsx.Pro` package (commercial).** Fluent `XlsxWorkbook(...)` API + ClosedXML
143+
`IReportSectionedWriter` (one named worksheet per section) + PolyForm Small Business LICENSE +
144+
metadata. Golden-file test.
145+
- [ ] **B1.4 — Packaging & CI.** Pro package builds/packs but is excluded from the OSS NuGet release.
146+
- [ ] **B1.5 — Sample** `06-multi-sheet-xlsx` (typed: Approved/Rejected sheets).
147+
- [ ] **B1.6 — Dynamic config** support for `xlsx-workbook` (optional, after the typed API settles).
143148

144149
### B2 — Multi-source reports (later)
145150

src/NeoReports.Core/Building/ReportBuilder.cs

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Linq.Expressions;
22
using NeoReports.Abstractions;
33
using NeoReports.Core.Pipeline;
4+
using NeoReports.Core.Sections;
45
using NeoReports.Core.Sources;
56

67
namespace NeoReports.Core.Building;
@@ -18,6 +19,7 @@ public sealed class ReportBuilder<TRow>
1819
private readonly List<Func<TRow, bool>> _filters = new();
1920
private readonly List<ColumnDefinition<TRow>> _columns = new();
2021
private readonly List<OutputEntry> _outputs = new();
22+
private readonly List<SectionedEntry> _sectioned = [];
2123
private readonly List<DestinationSpec> _destinations = new();
2224
private readonly RetryOptions _retry = new();
2325
private readonly FailureStrategyBuilder _failure = new();
@@ -153,6 +155,28 @@ public ReportBuilder<TRow> To(OutputSpec output, Action<OutputView<TRow>> config
153155
return this;
154156
}
155157

158+
/// <summary>
159+
/// Adds a sectioned output: a single file with several named sections (e.g. an XLSX workbook with
160+
/// one worksheet per section), each with its own filters and/or columns, all produced from one
161+
/// source read.
162+
/// </summary>
163+
/// <param name="output">The sectioned output specification (from a format package).</param>
164+
/// <param name="configureSections">Declares the sections (name + view).</param>
165+
/// <exception cref="ConfigurationException">Thrown when no sections are declared.</exception>
166+
public ReportBuilder<TRow> ToSections(SectionedOutputSpec output, Action<SectionBuilder<TRow>> configureSections)
167+
{
168+
ArgumentNullException.ThrowIfNull(output);
169+
ArgumentNullException.ThrowIfNull(configureSections);
170+
171+
var sections = new SectionBuilder<TRow>();
172+
configureSections(sections);
173+
if (sections.Sections.Count == 0)
174+
throw new ConfigurationException($"Report '{_name}' has a sectioned output with no sections. Call Section(...).");
175+
176+
_sectioned.Add(new SectionedEntry(output, sections.Sections));
177+
return this;
178+
}
179+
156180
/// <summary>Adds a destination the finished files are uploaded to.</summary>
157181
/// <param name="destination">The destination specification (e.g. from a destination package).</param>
158182
public ReportBuilder<TRow> UploadTo(DestinationSpec destination)
@@ -187,40 +211,50 @@ public CompiledReport Build()
187211
if (_batchSource is null && _streamingSource is null)
188212
throw new ConfigurationException($"Report '{_name}' has no source. Call From(...).");
189213

190-
if (_columns.Count == 0 && !_outputs.Any(o => o.View is { ViewColumns.Count: > 0 }))
191-
throw new ConfigurationException($"Report '{_name}' has no columns. Call Columns(...) or give an output view its own columns.");
214+
bool anyViewColumns =
215+
_outputs.Any(o => o.View is { ViewColumns.Count: > 0 }) ||
216+
_sectioned.Any(s => s.Sections.Any(sd => sd.View.ViewColumns.Count > 0));
217+
if (_columns.Count == 0 && !anyViewColumns)
218+
throw new ConfigurationException($"Report '{_name}' has no columns. Call Columns(...) or give a view its own columns.");
192219

193220
var reportSchema = new ReportSchema(_columns.Select(c => c.Column).ToList());
194221

195222
var outputSpecs = new OutputSpec[_outputs.Count];
196223
var outputSchemas = new ReportSchema[_outputs.Count];
197224
var projections = new OutputProjection<TRow>[_outputs.Count];
198-
199225
for (var i = 0; i < _outputs.Count; i++)
200226
{
201-
OutputEntry entry = _outputs[i];
202-
List<ColumnDefinition<TRow>> columns = entry.View is { ViewColumns.Count: > 0 } ? entry.View.ViewColumns : _columns;
203-
if (columns.Count == 0)
227+
(ReportSchema schema, OutputProjection<TRow> projection) = ResolveView(_outputs[i].View, $"output #{i + 1}");
228+
outputSpecs[i] = _outputs[i].Spec;
229+
outputSchemas[i] = schema;
230+
projections[i] = projection;
231+
}
232+
233+
var sectionedOutputs = new CompiledSectionedOutput[_sectioned.Count];
234+
var sectionedProjections = new IReadOnlyList<OutputProjection<TRow>>[_sectioned.Count];
235+
for (var s = 0; s < _sectioned.Count; s++)
236+
{
237+
SectionedEntry entry = _sectioned[s];
238+
var sectionMetas = new ReportSection[entry.Sections.Count];
239+
var sectionProjections = new OutputProjection<TRow>[entry.Sections.Count];
240+
for (var sec = 0; sec < entry.Sections.Count; sec++)
204241
{
205-
throw new ConfigurationException(
206-
$"Report '{_name}' output #{i + 1} has no columns. Add report Columns(...) or give the view its own columns.");
242+
SectionDefinition<TRow> def = entry.Sections[sec];
243+
(ReportSchema schema, OutputProjection<TRow> projection) = ResolveView(def.View, $"section '{def.Name}'");
244+
sectionMetas[sec] = new ReportSection(def.Name, schema);
245+
sectionProjections[sec] = projection;
207246
}
208247

209-
Func<TRow, bool>[] filters = entry.View is null || entry.View.ViewFilters.Count == 0
210-
? _filters.ToArray()
211-
: _filters.Concat(entry.View.ViewFilters).ToArray();
212-
213-
outputSpecs[i] = entry.Spec;
214-
outputSchemas[i] = new ReportSchema(columns.Select(c => c.Column).ToList());
215-
projections[i] = new OutputProjection<TRow>(filters, columns.Select(c => c.Getter).ToArray());
248+
sectionedOutputs[s] = new CompiledSectionedOutput(entry.Spec, sectionMetas);
249+
sectionedProjections[s] = sectionProjections;
216250
}
217251

218-
var batchSource = _batchSource;
219-
var streamingSource = _streamingSource;
220-
var pageSize = _pageSize;
252+
IBatchSource<TRow>? batchSource = _batchSource;
253+
IStreamingSource<TRow>? streamingSource = _streamingSource;
254+
int pageSize = _pageSize;
221255

222256
IProjectedBatchReader ReaderFactory(ReportExecutionContext execution) =>
223-
new TypedBatchReader<TRow>(batchSource, streamingSource, execution, pageSize, projections);
257+
new TypedBatchReader<TRow>(batchSource, streamingSource, execution, pageSize, projections, sectionedProjections);
224258

225259
return new CompiledReport(
226260
_name,
@@ -229,11 +263,33 @@ IProjectedBatchReader ReaderFactory(ReportExecutionContext execution) =>
229263
ReaderFactory,
230264
outputSpecs,
231265
outputSchemas,
266+
sectionedOutputs,
232267
_destinations.ToArray(),
233268
_retry,
234269
_failure.Build());
235270
}
236271

272+
private (ReportSchema Schema, OutputProjection<TRow> Projection) ResolveView(OutputView<TRow>? view, string what)
273+
{
274+
List<ColumnDefinition<TRow>> columns = view is { ViewColumns.Count: > 0 } ? view.ViewColumns : _columns;
275+
if (columns.Count == 0)
276+
{
277+
throw new ConfigurationException(
278+
$"Report '{_name}' {what} has no columns. Add report Columns(...) or give it its own columns.");
279+
}
280+
281+
Func<TRow, bool>[] filters = view is null || view.ViewFilters.Count == 0
282+
? _filters.ToArray()
283+
: _filters.Concat(view.ViewFilters).ToArray();
284+
285+
return (
286+
new ReportSchema(columns.Select(c => c.Column).ToList()),
287+
new OutputProjection<TRow>(filters, columns.Select(c => c.Getter).ToArray()));
288+
}
289+
237290
/// <summary>An output plus its optional per-output view (own filters/columns).</summary>
238291
private sealed record OutputEntry(OutputSpec Spec, OutputView<TRow>? View);
292+
293+
/// <summary>A sectioned output plus its section definitions.</summary>
294+
private sealed record SectionedEntry(SectionedOutputSpec Spec, IReadOnlyList<SectionDefinition<TRow>> Sections);
239295
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using NeoReports.Core.Sections;
2+
3+
namespace NeoReports.Core.Building;
4+
5+
/// <summary>
6+
/// A configured sectioned output: the factory that produces a single-file, multi-section writer
7+
/// (e.g. an XLSX workbook) plus its options. Section (sheet) definitions are supplied on the builder.
8+
/// </summary>
9+
public sealed class SectionedOutputSpec
10+
{
11+
/// <summary>Creates a sectioned output specification.</summary>
12+
/// <param name="factory">Factory that creates the sectioned writer.</param>
13+
/// <param name="options">Format-specific options; <c>null</c> is treated as empty.</param>
14+
public SectionedOutputSpec(ISectionedWriterFactory factory, IReadOnlyDictionary<string, object?>? options = null)
15+
{
16+
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
17+
Options = options ?? new Dictionary<string, object?>();
18+
}
19+
20+
/// <summary>Factory that creates the sectioned writer.</summary>
21+
public ISectionedWriterFactory Factory { get; }
22+
23+
/// <summary>Format-specific options.</summary>
24+
public IReadOnlyDictionary<string, object?> Options { get; }
25+
}
26+
27+
/// <summary>Collects the sections (e.g. worksheets) of a sectioned output, each with its own view.</summary>
28+
/// <typeparam name="TRow">The report's row type.</typeparam>
29+
public sealed class SectionBuilder<TRow>
30+
{
31+
internal List<SectionDefinition<TRow>> Sections { get; } = [];
32+
33+
/// <summary>Adds a named section with its own filters and/or columns.</summary>
34+
/// <param name="name">Section (sheet) name.</param>
35+
/// <param name="configureView">Configures the section's filters and columns.</param>
36+
public SectionBuilder<TRow> Section(string name, Action<OutputView<TRow>> configureView)
37+
{
38+
if (string.IsNullOrWhiteSpace(name))
39+
throw new ArgumentException("Section name must be provided.", nameof(name));
40+
ArgumentNullException.ThrowIfNull(configureView);
41+
42+
var view = new OutputView<TRow>();
43+
configureView(view);
44+
Sections.Add(new SectionDefinition<TRow>(name, view));
45+
return this;
46+
}
47+
}
48+
49+
/// <summary>One section's name and its view (filters + columns).</summary>
50+
internal sealed record SectionDefinition<TRow>(string Name, OutputView<TRow> View);

src/NeoReports.Core/CompiledReport.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
using NeoReports.Abstractions;
22
using NeoReports.Core.Building;
33
using NeoReports.Core.Pipeline;
4+
using NeoReports.Core.Sections;
45

56
namespace NeoReports.Core;
67

8+
/// <summary>A compiled sectioned output: the writer factory plus its sections (name + schema), in order.</summary>
9+
internal sealed record CompiledSectionedOutput(SectionedOutputSpec Spec, IReadOnlyList<ReportSection> Sections);
10+
711
/// <summary>
812
/// An immutable, type-erased report definition produced by <c>ReportBuilder&lt;T&gt;</c> and held
913
/// by the registry. The generic row type is captured inside <see cref="ReaderFactory"/>, so the
@@ -18,6 +22,7 @@ internal CompiledReport(
1822
Func<ReportExecutionContext, IProjectedBatchReader> readerFactory,
1923
IReadOnlyList<OutputSpec> outputs,
2024
IReadOnlyList<ReportSchema> outputSchemas,
25+
IReadOnlyList<CompiledSectionedOutput> sectionedOutputs,
2126
IReadOnlyList<DestinationSpec> destinations,
2227
RetryOptions retry,
2328
IFailureStrategy failureStrategy)
@@ -28,6 +33,7 @@ internal CompiledReport(
2833
ReaderFactory = readerFactory;
2934
Outputs = outputs;
3035
OutputSchemas = outputSchemas;
36+
SectionedOutputs = sectionedOutputs;
3137
Destinations = destinations;
3238
Retry = retry;
3339
FailureStrategy = failureStrategy;
@@ -54,6 +60,9 @@ internal CompiledReport(
5460
/// <summary>Schema of each output, aligned to <see cref="Outputs"/> (each output may have its own columns).</summary>
5561
internal IReadOnlyList<ReportSchema> OutputSchemas { get; }
5662

63+
/// <summary>Configured sectioned outputs (single file, several sections — e.g. an XLSX workbook).</summary>
64+
internal IReadOnlyList<CompiledSectionedOutput> SectionedOutputs { get; }
65+
5766
/// <summary>Configured destinations.</summary>
5867
internal IReadOnlyList<DestinationSpec> Destinations { get; }
5968

src/NeoReports.Core/Pipeline/IProjectedBatchReader.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ namespace NeoReports.Core.Pipeline;
1111
/// </param>
1212
/// <param name="NextCursor">Opaque cursor for the next page, or <c>null</c> when none.</param>
1313
/// <param name="HasMore">Whether more pages are expected after this one.</param>
14+
/// <param name="SectionedOutputs">
15+
/// Projected rows per sectioned output, then per section (e.g. workbook → worksheets), aligned to the
16+
/// compiled report's sectioned outputs. Empty when the report has no sectioned outputs.
17+
/// </param>
1418
/// <param name="RawCount">Number of records read before filtering (for statistics).</param>
15-
/// <param name="WrittenCount">Distinct source records written to at least one output (for statistics).</param>
19+
/// <param name="WrittenCount">Distinct source records written to at least one output/section (for statistics).</param>
1620
internal sealed record ProjectedBatch(
1721
IReadOnlyList<IReadOnlyList<object?[]>> Outputs,
22+
IReadOnlyList<IReadOnlyList<IReadOnlyList<object?[]>>> SectionedOutputs,
1823
string? NextCursor,
1924
bool HasMore,
2025
int RawCount,

0 commit comments

Comments
 (0)