Skip to content

Commit 827ff30

Browse files
authored
Merge pull request #59 from thiagoluga/feat/dynamic-a2-config-parser
feat(core): dynamic path A2 — config model, JSON parser and compiler
2 parents 203764e + 5a6615e commit 827ff30

12 files changed

Lines changed: 689 additions & 4 deletions

File tree

NeoReports.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.AspNetCore.Integ
5555
EndProject
5656
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "03-async-job-hangfire", "samples\03-async-job-hangfire\03-async-job-hangfire.csproj", "{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}"
5757
EndProject
58+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "04-dynamic-config-csv", "samples\04-dynamic-config-csv\04-dynamic-config-csv.csproj", "{90351642-8368-4814-A097-D7A1241A4C43}"
59+
EndProject
5860
Global
5961
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6062
Debug|Any CPU = Debug|Any CPU
@@ -329,6 +331,18 @@ Global
329331
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x64.Build.0 = Release|Any CPU
330332
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.ActiveCfg = Release|Any CPU
331333
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.Build.0 = Release|Any CPU
334+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
335+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|Any CPU.Build.0 = Debug|Any CPU
336+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|x64.ActiveCfg = Debug|Any CPU
337+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|x64.Build.0 = Debug|Any CPU
338+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|x86.ActiveCfg = Debug|Any CPU
339+
{90351642-8368-4814-A097-D7A1241A4C43}.Debug|x86.Build.0 = Debug|Any CPU
340+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|Any CPU.ActiveCfg = Release|Any CPU
341+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|Any CPU.Build.0 = Release|Any CPU
342+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x64.ActiveCfg = Release|Any CPU
343+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x64.Build.0 = Release|Any CPU
344+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x86.ActiveCfg = Release|Any CPU
345+
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x86.Build.0 = Release|Any CPU
332346
EndGlobalSection
333347
GlobalSection(SolutionProperties) = preSolution
334348
HideSolutionNode = FALSE
@@ -356,5 +370,6 @@ Global
356370
{DC55DB23-7998-47DC-9088-FA1B7B3821D1} = {11111111-1111-1111-1111-111111111111}
357371
{9477CA22-21CE-453F-9DC9-B99D874B2CD0} = {22222222-2222-2222-2222-222222222222}
358372
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD} = {44444444-4444-4444-4444-444444444444}
373+
{90351642-8368-4814-A097-D7A1241A4C43} = {44444444-4444-4444-4444-444444444444}
359374
EndGlobalSection
360375
EndGlobal

PLAN.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ destinations and jobs are untouched. See **D21**.
9191
v1 pipeline runs with `T = ReportRecord` unchanged. **Acceptance:** dynamic rows reach
9292
CSV byte-identically to the typed path for the same data. ✅ 26 green Core tests (+4).
9393
**Depends on:** v1.
94-
- [ ] **A2 — Config model + parser.** `ReportConfig` DTOs (source · columns ·
95-
filter · outputs · destinations · retry · onFailure) + `IReportConfigParser` (JSON).
96-
Maps a parsed config to a runnable registration. **Acceptance:** golden config →
97-
registered, runnable report. **Depends on:** A1.
94+
- [x] **A2 — Config model + parser.** Serializer-agnostic `ReportConfig` DTOs (source ·
95+
columns · outputs · destinations) + `IReportConfigParser` (JSON) in Abstractions;
96+
`ReportConfigCompiler` (Core) turns a parsed config into a runnable `CompiledReport`,
97+
resolving source/format/destination from DI by stable id (`IConfigSourceProvider`,
98+
`IWriterFactory`, `IDestinationFactory`). Filter is parsed but deferred to A4 (compiler
99+
rejects it explicitly). **Acceptance:** golden config → compiled, runnable report. ✅ 33
100+
green Core tests (+7). **Depends on:** A1.
98101
- [ ] **A3 — SQL source from config.** Keyset SQL source driven by config (connection
99102
name/string · sql · key · pageSize), materializing columns to `ReportRecord` by
100103
name/ordinal. **Acceptance:** Testcontainers E2E config→SQL→CSV. **Depends on:** A2, A3 reuses v1 keyset.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<IsPackable>false</IsPackable>
9+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\..\src\NeoReports.Core\NeoReports.Core.csproj" />
14+
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Csv\NeoReports.Formats.Csv.csproj" />
15+
<ProjectReference Include="..\..\src\Destinations\NeoReports.Destinations.Local\NeoReports.Destinations.Local.csproj" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
20+
<PackageReference Include="Microsoft.Extensions.Logging" />
21+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<None Update="report.json" CopyToOutputDirectory="PreserveNewest" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Globalization;
2+
using NeoReports.Abstractions;
3+
4+
namespace NeoReports.Samples.DynamicConfigCsv;
5+
6+
/// <summary>
7+
/// Stand-in <see cref="IConfigSourceProvider"/> (id "inmemory") used until the SQL config source
8+
/// lands in A3. It reads the row count from the source <c>properties</c> and emits positional
9+
/// <see cref="ReportRecord"/> rows aligned to the report schema — exactly what a real provider does,
10+
/// minus the database.
11+
/// </summary>
12+
internal sealed class InMemorySalesSourceProvider : IConfigSourceProvider
13+
{
14+
public string Type => "inmemory";
15+
16+
public IBatchSource<ReportRecord> Create(SourceConfig source, ReportSchema schema, IServiceProvider services)
17+
{
18+
var count = source.Properties is not null
19+
&& source.Properties.TryGetValue("rows", out var raw)
20+
&& raw is long n
21+
? (int)n
22+
: 5;
23+
24+
var rows = new List<ReportRecord>(count);
25+
for (var i = 1; i <= count; i++)
26+
{
27+
rows.Add(new ReportRecord(schema, new object?[]
28+
{
29+
(long)i,
30+
$"Customer {i}",
31+
i * 100.5m,
32+
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddDays(i - 1),
33+
}));
34+
}
35+
36+
return new InMemoryRecordSource(schema, rows);
37+
}
38+
39+
/// <summary>Serves the pre-built records one page at a time, driven by the page number.</summary>
40+
private sealed class InMemoryRecordSource : IBatchSource<ReportRecord>
41+
{
42+
private readonly IReadOnlyList<ReportRecord> _rows;
43+
44+
public InMemoryRecordSource(ReportSchema schema, IReadOnlyList<ReportRecord> rows)
45+
{
46+
Schema = schema;
47+
_rows = rows;
48+
}
49+
50+
public ReportSchema Schema { get; }
51+
52+
public Task<BatchResult<ReportRecord>> ReadBatchAsync(BatchContext context, CancellationToken cancellationToken)
53+
{
54+
var skip = (context.PageNumber - 1) * context.PageSize;
55+
var page = _rows.Skip(skip).Take(context.PageSize).ToArray();
56+
var hasMore = skip + page.Length < _rows.Count;
57+
var nextCursor = hasMore
58+
? context.PageNumber.ToString(CultureInfo.InvariantCulture)
59+
: null;
60+
return Task.FromResult(new BatchResult<ReportRecord>(page, nextCursor, hasMore));
61+
}
62+
}
63+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Logging;
3+
using NeoReports.Abstractions;
4+
using NeoReports.Core.Configuration;
5+
using NeoReports.Core.Pipeline;
6+
using NeoReports.Destinations.Local;
7+
using NeoReports.Formats.Csv;
8+
using NeoReports.Samples.DynamicConfigCsv;
9+
10+
// Sample 04 — config-driven report (the dynamic path).
11+
//
12+
// The whole report is defined in report.json, with no typed POCO. Rows flow through the same
13+
// pipeline as the typed path, as positional ReportRecords. The JSON fully drives the report name,
14+
// the source selection by id, the columns and schema (name, type, header, format and culture) and
15+
// the selection of outputs and destinations by id.
16+
//
17+
// Two pieces are still standing in. The SQL config source arrives in A3, so for now an in-memory
18+
// source provider supplies the rows. Binding format and destination options from config arrives
19+
// later in A5, so the CSV and Local factories are pre-wired in DI and the JSON properties under
20+
// outputs and destinations are illustrative.
21+
//
22+
// Run with: dotnet run --project samples/04-dynamic-config-csv
23+
24+
var configPath = Path.Combine(AppContext.BaseDirectory, "report.json");
25+
var json = await File.ReadAllTextAsync(configPath);
26+
27+
// 1) Parse the JSON document into a ReportConfig.
28+
var config = new JsonReportConfigParser().Parse(json);
29+
30+
// 2) Register the providers/factories the compiler resolves by stable id.
31+
var services = new ServiceCollection();
32+
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
33+
services.AddSingleton<IConfigSourceProvider, InMemorySalesSourceProvider>(); // source "inmemory"
34+
services.AddSingleton<IWriterFactory>(new CsvWriterFactory(new CsvOptions())); // format "csv"
35+
services.AddSingleton<IDestinationFactory>(
36+
new LocalDestinationFactory("./out/{name}-{date:yyyy-MM-dd}.{ext}")); // dest "local"
37+
38+
await using var provider = services.BuildServiceProvider();
39+
40+
// 3) Compile the config into the same runnable report the fluent builder produces.
41+
var report = ReportConfigCompiler.Compile(config, provider);
42+
43+
// 4) Run it.
44+
var logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("dynamic");
45+
var exec = new ReportExecutionContext(
46+
Guid.NewGuid().ToString("N"), config.Name, parameters: null, logger, CancellationToken.None);
47+
48+
var result = await ReportRunner.ExecuteAsync(report, exec, provider, CancellationToken.None);
49+
50+
Console.WriteLine($"Report: {config.Name}");
51+
Console.WriteLine($"Status: {result.Status}");
52+
Console.WriteLine($"Records read/written: {result.Stats.RecordsRead}/{result.Stats.RecordsWritten}");
53+
foreach (var upload in result.Uploads)
54+
Console.WriteLine($"Uploaded: {upload.RemotePath} (success={upload.Success})");
55+
56+
return result.Status == ReportRunStatus.Failed ? 1 : 0;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# 04 — dynamic config → CSV (the dynamic path)
2+
3+
Defines a report entirely in [`report.json`](report.json)**no typed POCO**. The JSON is parsed
4+
into a `ReportConfig` and compiled into the same runnable report the fluent builder produces; rows
5+
flow through the existing pipeline as positional `ReportRecord`s.
6+
7+
```bash
8+
dotnet run --project samples/04-dynamic-config-csv
9+
# writes ./out/monthly-sales-<date>.csv
10+
```
11+
12+
What the JSON drives today:
13+
14+
- the report **name** and **page size**;
15+
- the **source** selection by id (`"type": "inmemory"`) and its properties (`rows`);
16+
- the **columns / schema**: name, semantic type, header, format and culture;
17+
- the **outputs** and **destinations** selection by id (`"csv"`, `"local"`).
18+
19+
Standing in for not-yet-built pieces:
20+
21+
- the **SQL** config source arrives in **A3**, so an in-memory `IConfigSourceProvider`
22+
([`InMemorySalesSourceProvider`](InMemorySalesSourceProvider.cs)) supplies the rows;
23+
- binding format/destination **options** from config arrives later (**A5**), so the CSV and Local
24+
factories are pre-wired in DI — the JSON's `properties` under `outputs`/`destinations` are
25+
illustrative for now.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "monthly-sales",
3+
"pageSize": 100,
4+
"source": {
5+
"type": "inmemory",
6+
"properties": { "rows": 5 }
7+
},
8+
"columns": [
9+
{ "name": "Id", "type": "Integer", "displayName": "Sale ID", "nullable": false },
10+
{ "name": "Customer", "type": "String" },
11+
{ "name": "Amount", "type": "Decimal", "displayName": "Amount", "format": "C2", "culture": "pt-BR" },
12+
{ "name": "Date", "type": "DateTime", "displayName": "Sale Date", "format": "yyyy-MM-dd" }
13+
],
14+
"outputs": [ { "format": "csv" } ],
15+
"destinations": [
16+
{ "type": "local", "properties": { "path": "./out/{name}-{date:yyyy-MM-dd}.{ext}" } }
17+
]
18+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
namespace NeoReports.Abstractions;
2+
3+
// Serializer-agnostic configuration model for the dynamic (config-driven) path. These are plain
4+
// records with no JSON coupling; a parser (e.g. the Core JSON parser) maps a document onto them,
5+
// and a compiler turns them into the same runnable report a fluent builder produces. The model
6+
// mirrors the fluent builder one-to-one.
7+
8+
/// <summary>
9+
/// A complete report definition expressed as data (the dynamic path). A compiler turns this into
10+
/// the same runnable report that <c>ReportBuilder&lt;ReportRecord&gt;</c> produces in code.
11+
/// </summary>
12+
/// <param name="Name">Unique report name.</param>
13+
/// <param name="Source">The source the rows are read from.</param>
14+
/// <param name="Columns">Output columns, in order; they define the positional schema.</param>
15+
/// <param name="Outputs">Output formats (at least one).</param>
16+
/// <param name="Destinations">Upload destinations; <c>null</c> or empty means none.</param>
17+
/// <param name="PageSize">Optional page size; the engine default is used when null.</param>
18+
/// <param name="Filter">Optional dynamic filter expression (JsonLogic); evaluated by a later epic.</param>
19+
public sealed record ReportConfig(
20+
string Name,
21+
SourceConfig Source,
22+
IReadOnlyList<ColumnConfig> Columns,
23+
IReadOnlyList<OutputConfig> Outputs,
24+
IReadOnlyList<DestinationConfig>? Destinations = null,
25+
int? PageSize = null,
26+
string? Filter = null);
27+
28+
/// <summary>A source section: a stable type id plus a free-form property bag the provider reads.</summary>
29+
/// <param name="Type">Stable source type id (e.g. "sql"); resolved to an <see cref="IConfigSourceProvider"/>.</param>
30+
/// <param name="Properties">Provider-specific settings (e.g. connection string, query, key).</param>
31+
public sealed record SourceConfig(
32+
string Type,
33+
IReadOnlyDictionary<string, object?>? Properties = null);
34+
35+
/// <summary>
36+
/// A single output column declared as data. Because a dynamic row is positional, the column's
37+
/// position is its index in <see cref="ReportConfig.Columns"/>; the rest mirrors <see cref="ReportColumn"/>.
38+
/// </summary>
39+
/// <param name="Name">Stable column key, unique within the report.</param>
40+
/// <param name="Type">Semantic column type used for formatting and projection.</param>
41+
/// <param name="DisplayName">Optional header label; defaults to <paramref name="Name"/>.</param>
42+
/// <param name="Format">Optional .NET format string for rendering.</param>
43+
/// <param name="Culture">Optional culture name (e.g. "pt-BR") for rendering.</param>
44+
/// <param name="Nullable">Whether the column may contain null values.</param>
45+
public sealed record ColumnConfig(
46+
string Name,
47+
ColumnType Type,
48+
string? DisplayName = null,
49+
string? Format = null,
50+
string? Culture = null,
51+
bool Nullable = true);
52+
53+
/// <summary>An output section: a stable format id plus a free-form property bag the writer reads.</summary>
54+
/// <param name="Format">Stable format id (e.g. "csv", "xlsx"); resolved to an <see cref="IWriterFactory"/>.</param>
55+
/// <param name="Properties">Format-specific options.</param>
56+
public sealed record OutputConfig(
57+
string Format,
58+
IReadOnlyDictionary<string, object?>? Properties = null);
59+
60+
/// <summary>A destination section: a stable type id plus a free-form property bag.</summary>
61+
/// <param name="Type">Stable destination type id (e.g. "local", "s3"); resolved to an <see cref="IDestinationFactory"/>.</param>
62+
/// <param name="Properties">Destination-specific options (e.g. path/key template, bucket).</param>
63+
public sealed record DestinationConfig(
64+
string Type,
65+
IReadOnlyDictionary<string, object?>? Properties = null);
66+
67+
/// <summary>Parses a serialized report definition (e.g. JSON) into a <see cref="ReportConfig"/>.</summary>
68+
public interface IReportConfigParser
69+
{
70+
/// <summary>Parses the given document into a report configuration.</summary>
71+
/// <param name="document">The serialized configuration (e.g. a JSON string).</param>
72+
/// <returns>The parsed configuration.</returns>
73+
/// <exception cref="ConfigurationException">Thrown when the document is missing or malformed.</exception>
74+
ReportConfig Parse(string document);
75+
}
76+
77+
/// <summary>
78+
/// Builds a positional <see cref="ReportRecord"/> source from a <see cref="SourceConfig"/>. The
79+
/// dynamic equivalent of a typed source factory: providers are registered by <see cref="Type"/> and
80+
/// resolved by the config compiler. The output schema is supplied so the provider can align values
81+
/// to columns by name/position.
82+
/// </summary>
83+
public interface IConfigSourceProvider
84+
{
85+
/// <summary>Stable source type id this provider handles (e.g. "sql"); matched case-insensitively.</summary>
86+
string Type { get; }
87+
88+
/// <summary>Creates the batch source that yields positional records aligned to <paramref name="schema"/>.</summary>
89+
/// <param name="source">The source configuration section.</param>
90+
/// <param name="schema">The report's output schema (columns in order).</param>
91+
/// <param name="services">The service provider for resolving dependencies.</param>
92+
/// <returns>A batch source producing <see cref="ReportRecord"/> rows.</returns>
93+
IBatchSource<ReportRecord> Create(SourceConfig source, ReportSchema schema, IServiceProvider services);
94+
}

0 commit comments

Comments
 (0)