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

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

Expand Down
94 changes: 75 additions & 19 deletions src/NeoReports.Core/Building/ReportBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Linq.Expressions;
using NeoReports.Abstractions;
using NeoReports.Core.Pipeline;
using NeoReports.Core.Sections;
using NeoReports.Core.Sources;

namespace NeoReports.Core.Building;
Expand All @@ -18,6 +19,7 @@ public sealed class ReportBuilder<TRow>
private readonly List<Func<TRow, bool>> _filters = new();
private readonly List<ColumnDefinition<TRow>> _columns = new();
private readonly List<OutputEntry> _outputs = new();
private readonly List<SectionedEntry> _sectioned = [];
private readonly List<DestinationSpec> _destinations = new();
private readonly RetryOptions _retry = new();
private readonly FailureStrategyBuilder _failure = new();
Expand Down Expand Up @@ -153,6 +155,28 @@ public ReportBuilder<TRow> To(OutputSpec output, Action<OutputView<TRow>> config
return this;
}

/// <summary>
/// Adds a sectioned output: a single file with several named sections (e.g. an XLSX workbook with
/// one worksheet per section), each with its own filters and/or columns, all produced from one
/// source read.
/// </summary>
/// <param name="output">The sectioned output specification (from a format package).</param>
/// <param name="configureSections">Declares the sections (name + view).</param>
/// <exception cref="ConfigurationException">Thrown when no sections are declared.</exception>
public ReportBuilder<TRow> ToSections(SectionedOutputSpec output, Action<SectionBuilder<TRow>> configureSections)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(configureSections);

var sections = new SectionBuilder<TRow>();
configureSections(sections);
if (sections.Sections.Count == 0)
throw new ConfigurationException($"Report '{_name}' has a sectioned output with no sections. Call Section(...).");

_sectioned.Add(new SectionedEntry(output, sections.Sections));
return this;
}

/// <summary>Adds a destination the finished files are uploaded to.</summary>
/// <param name="destination">The destination specification (e.g. from a destination package).</param>
public ReportBuilder<TRow> UploadTo(DestinationSpec destination)
Expand Down Expand Up @@ -187,40 +211,50 @@ public CompiledReport Build()
if (_batchSource is null && _streamingSource is null)
throw new ConfigurationException($"Report '{_name}' has no source. Call From(...).");

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

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

var outputSpecs = new OutputSpec[_outputs.Count];
var outputSchemas = new ReportSchema[_outputs.Count];
var projections = new OutputProjection<TRow>[_outputs.Count];

for (var i = 0; i < _outputs.Count; i++)
{
OutputEntry entry = _outputs[i];
List<ColumnDefinition<TRow>> columns = entry.View is { ViewColumns.Count: > 0 } ? entry.View.ViewColumns : _columns;
if (columns.Count == 0)
(ReportSchema schema, OutputProjection<TRow> projection) = ResolveView(_outputs[i].View, $"output #{i + 1}");
outputSpecs[i] = _outputs[i].Spec;
outputSchemas[i] = schema;
projections[i] = projection;
}

var sectionedOutputs = new CompiledSectionedOutput[_sectioned.Count];
var sectionedProjections = new IReadOnlyList<OutputProjection<TRow>>[_sectioned.Count];
for (var s = 0; s < _sectioned.Count; s++)
{
SectionedEntry entry = _sectioned[s];
var sectionMetas = new ReportSection[entry.Sections.Count];
var sectionProjections = new OutputProjection<TRow>[entry.Sections.Count];
for (var sec = 0; sec < entry.Sections.Count; sec++)
{
throw new ConfigurationException(
$"Report '{_name}' output #{i + 1} has no columns. Add report Columns(...) or give the view its own columns.");
SectionDefinition<TRow> def = entry.Sections[sec];
(ReportSchema schema, OutputProjection<TRow> projection) = ResolveView(def.View, $"section '{def.Name}'");
sectionMetas[sec] = new ReportSection(def.Name, schema);
sectionProjections[sec] = projection;
}

Func<TRow, bool>[] filters = entry.View is null || entry.View.ViewFilters.Count == 0
? _filters.ToArray()
: _filters.Concat(entry.View.ViewFilters).ToArray();

outputSpecs[i] = entry.Spec;
outputSchemas[i] = new ReportSchema(columns.Select(c => c.Column).ToList());
projections[i] = new OutputProjection<TRow>(filters, columns.Select(c => c.Getter).ToArray());
sectionedOutputs[s] = new CompiledSectionedOutput(entry.Spec, sectionMetas);
sectionedProjections[s] = sectionProjections;
}

var batchSource = _batchSource;
var streamingSource = _streamingSource;
var pageSize = _pageSize;
IBatchSource<TRow>? batchSource = _batchSource;
IStreamingSource<TRow>? streamingSource = _streamingSource;
int pageSize = _pageSize;

IProjectedBatchReader ReaderFactory(ReportExecutionContext execution) =>
new TypedBatchReader<TRow>(batchSource, streamingSource, execution, pageSize, projections);
new TypedBatchReader<TRow>(batchSource, streamingSource, execution, pageSize, projections, sectionedProjections);

return new CompiledReport(
_name,
Expand All @@ -229,11 +263,33 @@ IProjectedBatchReader ReaderFactory(ReportExecutionContext execution) =>
ReaderFactory,
outputSpecs,
outputSchemas,
sectionedOutputs,
_destinations.ToArray(),
_retry,
_failure.Build());
}

private (ReportSchema Schema, OutputProjection<TRow> Projection) ResolveView(OutputView<TRow>? view, string what)
{
List<ColumnDefinition<TRow>> columns = view is { ViewColumns.Count: > 0 } ? view.ViewColumns : _columns;
if (columns.Count == 0)
{
throw new ConfigurationException(
$"Report '{_name}' {what} has no columns. Add report Columns(...) or give it its own columns.");
}

Func<TRow, bool>[] filters = view is null || view.ViewFilters.Count == 0
? _filters.ToArray()
: _filters.Concat(view.ViewFilters).ToArray();

return (
new ReportSchema(columns.Select(c => c.Column).ToList()),
new OutputProjection<TRow>(filters, columns.Select(c => c.Getter).ToArray()));
}

/// <summary>An output plus its optional per-output view (own filters/columns).</summary>
private sealed record OutputEntry(OutputSpec Spec, OutputView<TRow>? View);

/// <summary>A sectioned output plus its section definitions.</summary>
private sealed record SectionedEntry(SectionedOutputSpec Spec, IReadOnlyList<SectionDefinition<TRow>> Sections);
}
50 changes: 50 additions & 0 deletions src/NeoReports.Core/Building/SectionedOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using NeoReports.Core.Sections;

namespace NeoReports.Core.Building;

/// <summary>
/// A configured sectioned output: the factory that produces a single-file, multi-section writer
/// (e.g. an XLSX workbook) plus its options. Section (sheet) definitions are supplied on the builder.
/// </summary>
public sealed class SectionedOutputSpec
{
/// <summary>Creates a sectioned output specification.</summary>
/// <param name="factory">Factory that creates the sectioned writer.</param>
/// <param name="options">Format-specific options; <c>null</c> is treated as empty.</param>
public SectionedOutputSpec(ISectionedWriterFactory factory, IReadOnlyDictionary<string, object?>? options = null)
{
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
Options = options ?? new Dictionary<string, object?>();
}

/// <summary>Factory that creates the sectioned writer.</summary>
public ISectionedWriterFactory Factory { get; }

/// <summary>Format-specific options.</summary>
public IReadOnlyDictionary<string, object?> Options { get; }
}

/// <summary>Collects the sections (e.g. worksheets) of a sectioned output, each with its own view.</summary>
/// <typeparam name="TRow">The report's row type.</typeparam>
public sealed class SectionBuilder<TRow>
{
internal List<SectionDefinition<TRow>> Sections { get; } = [];

/// <summary>Adds a named section with its own filters and/or columns.</summary>
/// <param name="name">Section (sheet) name.</param>
/// <param name="configureView">Configures the section's filters and columns.</param>
public SectionBuilder<TRow> Section(string name, Action<OutputView<TRow>> configureView)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Section name must be provided.", nameof(name));
ArgumentNullException.ThrowIfNull(configureView);

var view = new OutputView<TRow>();
configureView(view);
Sections.Add(new SectionDefinition<TRow>(name, view));
return this;
}
}

/// <summary>One section's name and its view (filters + columns).</summary>
internal sealed record SectionDefinition<TRow>(string Name, OutputView<TRow> View);
9 changes: 9 additions & 0 deletions src/NeoReports.Core/CompiledReport.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using NeoReports.Abstractions;
using NeoReports.Core.Building;
using NeoReports.Core.Pipeline;
using NeoReports.Core.Sections;

namespace NeoReports.Core;

/// <summary>A compiled sectioned output: the writer factory plus its sections (name + schema), in order.</summary>
internal sealed record CompiledSectionedOutput(SectionedOutputSpec Spec, IReadOnlyList<ReportSection> Sections);

/// <summary>
/// An immutable, type-erased report definition produced by <c>ReportBuilder&lt;T&gt;</c> and held
/// by the registry. The generic row type is captured inside <see cref="ReaderFactory"/>, so the
Expand All @@ -11,13 +15,14 @@
/// </summary>
public sealed class CompiledReport
{
internal CompiledReport(

Check warning on line 18 in src/NeoReports.Core/CompiledReport.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Constructor has 10 parameters, which is greater than the 7 authorized.

Check warning on line 18 in src/NeoReports.Core/CompiledReport.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Constructor has 10 parameters, which is greater than the 7 authorized.

Check warning on line 18 in src/NeoReports.Core/CompiledReport.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Constructor has 10 parameters, which is greater than the 7 authorized.

Check warning on line 18 in src/NeoReports.Core/CompiledReport.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Constructor has 10 parameters, which is greater than the 7 authorized.
string name,
ReportSchema schema,
int pageSize,
Func<ReportExecutionContext, IProjectedBatchReader> readerFactory,
IReadOnlyList<OutputSpec> outputs,
IReadOnlyList<ReportSchema> outputSchemas,
IReadOnlyList<CompiledSectionedOutput> sectionedOutputs,
IReadOnlyList<DestinationSpec> destinations,
RetryOptions retry,
IFailureStrategy failureStrategy)
Expand All @@ -28,6 +33,7 @@
ReaderFactory = readerFactory;
Outputs = outputs;
OutputSchemas = outputSchemas;
SectionedOutputs = sectionedOutputs;
Destinations = destinations;
Retry = retry;
FailureStrategy = failureStrategy;
Expand All @@ -54,6 +60,9 @@
/// <summary>Schema of each output, aligned to <see cref="Outputs"/> (each output may have its own columns).</summary>
internal IReadOnlyList<ReportSchema> OutputSchemas { get; }

/// <summary>Configured sectioned outputs (single file, several sections — e.g. an XLSX workbook).</summary>
internal IReadOnlyList<CompiledSectionedOutput> SectionedOutputs { get; }

/// <summary>Configured destinations.</summary>
internal IReadOnlyList<DestinationSpec> Destinations { get; }

Expand Down
7 changes: 6 additions & 1 deletion src/NeoReports.Core/Pipeline/IProjectedBatchReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ namespace NeoReports.Core.Pipeline;
/// </param>
/// <param name="NextCursor">Opaque cursor for the next page, or <c>null</c> when none.</param>
/// <param name="HasMore">Whether more pages are expected after this one.</param>
/// <param name="SectionedOutputs">
/// Projected rows per sectioned output, then per section (e.g. workbook → worksheets), aligned to the
/// compiled report's sectioned outputs. Empty when the report has no sectioned outputs.
/// </param>
/// <param name="RawCount">Number of records read before filtering (for statistics).</param>
/// <param name="WrittenCount">Distinct source records written to at least one output (for statistics).</param>
/// <param name="WrittenCount">Distinct source records written to at least one output/section (for statistics).</param>
internal sealed record ProjectedBatch(
IReadOnlyList<IReadOnlyList<object?[]>> Outputs,
IReadOnlyList<IReadOnlyList<IReadOnlyList<object?[]>>> SectionedOutputs,
string? NextCursor,
bool HasMore,
int RawCount,
Expand Down
Loading