Skip to content

Commit 4e31365

Browse files
committed
feat(core): dynamic path A2 — config model, JSON parser and compiler
Second step of Epic A (D21). Reports can now be expressed as data and compiled into the same runnable report the fluent path produces — no parallel pipeline. - Abstractions: serializer-agnostic config DTOs (ReportConfig, SourceConfig, ColumnConfig, OutputConfig, DestinationConfig), the IReportConfigParser contract, and IConfigSourceProvider (the dynamic equivalent of a typed source factory). Additive, SemVer-minor (D25); the DTOs carry no JSON coupling. - Core: JsonReportConfigParser (System.Text.Json; case-insensitive, string enums, property-bag values converted to CLR primitives / ISO DateTime like JobParameters) and ReportConfigCompiler, which builds a CompiledReport over the positional ReportRecord. Columns become Positional(...) getters; source/format/destination are resolved from DI by stable id (IConfigSourceProvider / IWriterFactory.Format / IDestinationFactory.Type), all resolved up front so a missing registration fails fast before the source is built. - Filter is parsed but compilation is deferred to A4 (the compiler rejects it explicitly). Tests (+7, 33 green Core): full parse with primitive coercion; empty/malformed rejection; config compiled and run end-to-end through ReportRunner; filter and missing-factory both surface a ConfigurationException.
1 parent 203764e commit 4e31365

6 files changed

Lines changed: 490 additions & 4 deletions

File tree

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: 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+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System.Globalization;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
using NeoReports.Abstractions;
5+
6+
namespace NeoReports.Core.Configuration;
7+
8+
/// <summary>
9+
/// Default <see cref="IReportConfigParser"/> that reads a JSON document into a
10+
/// <see cref="ReportConfig"/>. Property names are matched case-insensitively, enums (e.g.
11+
/// <see cref="ColumnType"/>) are read from strings, and free-form <c>properties</c> values are
12+
/// converted to CLR primitives (string, long, double, bool, ISO-8601 <see cref="DateTime"/>) so
13+
/// downstream providers/writers receive usable values instead of raw <see cref="JsonElement"/>s.
14+
/// </summary>
15+
public sealed class JsonReportConfigParser : IReportConfigParser
16+
{
17+
private static readonly JsonSerializerOptions Options = CreateOptions();
18+
19+
/// <inheritdoc />
20+
public ReportConfig Parse(string document)
21+
{
22+
if (string.IsNullOrWhiteSpace(document))
23+
throw new ConfigurationException("Report configuration document is empty.");
24+
25+
ReportConfig? config;
26+
try
27+
{
28+
config = JsonSerializer.Deserialize<ReportConfig>(document, Options);
29+
}
30+
catch (JsonException ex)
31+
{
32+
throw new ConfigurationException($"Invalid report configuration JSON: {ex.Message}", ex);
33+
}
34+
35+
if (config is null)
36+
throw new ConfigurationException("Report configuration JSON deserialized to null.");
37+
38+
return config;
39+
}
40+
41+
private static JsonSerializerOptions CreateOptions()
42+
{
43+
var options = new JsonSerializerOptions
44+
{
45+
PropertyNameCaseInsensitive = true,
46+
ReadCommentHandling = JsonCommentHandling.Skip,
47+
AllowTrailingCommas = true,
48+
};
49+
options.Converters.Add(new JsonStringEnumConverter());
50+
options.Converters.Add(new PrimitiveObjectConverter());
51+
return options;
52+
}
53+
54+
/// <summary>
55+
/// Reads JSON values typed as <c>object?</c> (the property-bag values) into CLR primitives.
56+
/// Nested objects/arrays are preserved as a cloned <see cref="JsonElement"/>.
57+
/// </summary>
58+
private sealed class PrimitiveObjectConverter : JsonConverter<object?>
59+
{
60+
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
61+
reader.TokenType switch
62+
{
63+
JsonTokenType.Null => null,
64+
JsonTokenType.True => true,
65+
JsonTokenType.False => false,
66+
JsonTokenType.String => ConvertString(reader.GetString()),
67+
JsonTokenType.Number => reader.TryGetInt64(out var l) ? l : reader.GetDouble(),
68+
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone(),
69+
};
70+
71+
public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
72+
{
73+
switch (value)
74+
{
75+
case null:
76+
writer.WriteNullValue();
77+
break;
78+
case string s:
79+
writer.WriteStringValue(s);
80+
break;
81+
case bool b:
82+
writer.WriteBooleanValue(b);
83+
break;
84+
case long l:
85+
writer.WriteNumberValue(l);
86+
break;
87+
case double d:
88+
writer.WriteNumberValue(d);
89+
break;
90+
case DateTime dt:
91+
writer.WriteStringValue(dt.ToString("O", CultureInfo.InvariantCulture));
92+
break;
93+
default:
94+
JsonSerializer.Serialize(writer, value, value.GetType(), options);
95+
break;
96+
}
97+
}
98+
99+
private static object? ConvertString(string? text)
100+
{
101+
if (text is null)
102+
return null;
103+
104+
// Recover round-tripped ISO-8601 timestamps as DateTime so date parameters bind
105+
// correctly downstream. RoundtripKind honors any 'Z'/offset and must not be combined
106+
// with AdjustToUniversal/AssumeUniversal (.NET rejects that pairing).
107+
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
108+
return dt;
109+
110+
return text;
111+
}
112+
}
113+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using NeoReports.Abstractions;
3+
using NeoReports.Core.Building;
4+
5+
namespace NeoReports.Core.Configuration;
6+
7+
/// <summary>
8+
/// Compiles a <see cref="ReportConfig"/> into a runnable <see cref="CompiledReport"/> over the
9+
/// positional <see cref="ReportRecord"/> row. The source/format/destination of a config section are
10+
/// resolved from DI by their stable ids: an <see cref="IConfigSourceProvider"/> for the source, an
11+
/// <see cref="IWriterFactory"/> per output format, and an <see cref="IDestinationFactory"/> per
12+
/// destination type. The rest is the same fluent build the typed path uses — no parallel pipeline.
13+
/// </summary>
14+
public static class ReportConfigCompiler
15+
{
16+
/// <summary>Compiles a parsed configuration into a runnable report.</summary>
17+
/// <param name="config">The parsed report configuration.</param>
18+
/// <param name="services">Service provider that holds the registered providers/factories.</param>
19+
/// <returns>An immutable compiled report ready to run or register.</returns>
20+
/// <exception cref="ConfigurationException">Thrown when the config is invalid or a referenced provider/factory is not registered.</exception>
21+
public static CompiledReport Compile(ReportConfig config, IServiceProvider services)
22+
{
23+
ArgumentNullException.ThrowIfNull(config);
24+
ArgumentNullException.ThrowIfNull(services);
25+
26+
if (string.IsNullOrWhiteSpace(config.Name))
27+
throw new ConfigurationException("Report configuration has no name.");
28+
if (config.Source is null)
29+
throw new ConfigurationException($"Report '{config.Name}' has no source.");
30+
if (config.Columns is null || config.Columns.Count == 0)
31+
throw new ConfigurationException($"Report '{config.Name}' has no columns.");
32+
if (config.Outputs is null || config.Outputs.Count == 0)
33+
throw new ConfigurationException($"Report '{config.Name}' has no outputs.");
34+
if (config.Filter is not null)
35+
{
36+
throw new ConfigurationException(
37+
$"Report '{config.Name}' declares a filter, but dynamic filters require the JsonLogic " +
38+
"compiler (Epic A4), which is not available yet.");
39+
}
40+
41+
var columns = new ColumnDefinition<ReportRecord>[config.Columns.Count];
42+
for (var i = 0; i < config.Columns.Count; i++)
43+
{
44+
var c = config.Columns[i];
45+
columns[i] = ReportColumns.Positional(i, c.Name, c.Type, c.Nullable, c.DisplayName, c.Format, c.Culture);
46+
}
47+
48+
var schema = new ReportSchema(columns.Select(c => c.Column).ToList());
49+
50+
// Resolve every registration up front (fail fast on a missing provider/factory) before
51+
// instantiating the source, which may open connections.
52+
var sourceProvider = ResolveSource(services, config.Source.Type);
53+
var outputs = config.Outputs
54+
.Select(o => new OutputSpec(ResolveWriter(services, o.Format), o.Properties))
55+
.ToArray();
56+
var destinations = config.Destinations?
57+
.Select(d => new DestinationSpec(ResolveDestination(services, d.Type), d.Properties))
58+
.ToArray() ?? Array.Empty<DestinationSpec>();
59+
60+
var source = sourceProvider.Create(config.Source, schema, services);
61+
62+
var builder = new ReportBuilder<ReportRecord>(config.Name)
63+
.From(source)
64+
.Columns(columns);
65+
66+
if (config.PageSize is int pageSize)
67+
builder.WithPageSize(pageSize);
68+
69+
foreach (var output in outputs)
70+
builder.To(output);
71+
foreach (var destination in destinations)
72+
builder.UploadTo(destination);
73+
74+
return builder.Build();
75+
}
76+
77+
private static IConfigSourceProvider ResolveSource(IServiceProvider services, string type)
78+
{
79+
var provider = services.GetServices<IConfigSourceProvider>()
80+
.FirstOrDefault(p => string.Equals(p.Type, type, StringComparison.OrdinalIgnoreCase));
81+
return provider ?? throw new ConfigurationException(
82+
$"No source provider is registered for type '{type}'. Register an IConfigSourceProvider with that Type.");
83+
}
84+
85+
private static IWriterFactory ResolveWriter(IServiceProvider services, string format)
86+
{
87+
var factory = services.GetServices<IWriterFactory>()
88+
.FirstOrDefault(f => string.Equals(f.Format, format, StringComparison.OrdinalIgnoreCase));
89+
return factory ?? throw new ConfigurationException(
90+
$"No writer factory is registered for format '{format}'. Register an IWriterFactory with that Format.");
91+
}
92+
93+
private static IDestinationFactory ResolveDestination(IServiceProvider services, string type)
94+
{
95+
var factory = services.GetServices<IDestinationFactory>()
96+
.FirstOrDefault(f => string.Equals(f.Type, type, StringComparison.OrdinalIgnoreCase));
97+
return factory ?? throw new ConfigurationException(
98+
$"No destination factory is registered for type '{type}'. Register an IDestinationFactory with that Type.");
99+
}
100+
}

0 commit comments

Comments
 (0)