Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions NeoReports.sln
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "03-async-job-hangfire", "sa
EndProject
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}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "05-dynamic-config-sql", "samples\05-dynamic-config-sql\05-dynamic-config-sql.csproj", "{50A22538-2819-43C2-A70B-656DFA0A2759}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -343,6 +345,18 @@ Global
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x64.Build.0 = Release|Any CPU
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x86.ActiveCfg = Release|Any CPU
{90351642-8368-4814-A097-D7A1241A4C43}.Release|x86.Build.0 = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|Any CPU.Build.0 = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|x64.ActiveCfg = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|x64.Build.0 = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|x86.ActiveCfg = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Debug|x86.Build.0 = Debug|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|Any CPU.ActiveCfg = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|Any CPU.Build.0 = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|x64.ActiveCfg = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|x64.Build.0 = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|x86.ActiveCfg = Release|Any CPU
{50A22538-2819-43C2-A70B-656DFA0A2759}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -371,5 +385,6 @@ Global
{9477CA22-21CE-453F-9DC9-B99D874B2CD0} = {22222222-2222-2222-2222-222222222222}
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD} = {44444444-4444-4444-4444-444444444444}
{90351642-8368-4814-A097-D7A1241A4C43} = {44444444-4444-4444-4444-444444444444}
{50A22538-2819-43C2-A70B-656DFA0A2759} = {44444444-4444-4444-4444-444444444444}
EndGlobalSection
EndGlobal
9 changes: 6 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,12 @@ destinations and jobs are untouched. See **D21**.
`IWriterFactory`, `IDestinationFactory`). Filter is parsed but deferred to A4 (compiler
rejects it explicitly). **Acceptance:** golden config → compiled, runnable report. ✅ 33
green Core tests (+7). **Depends on:** A1.
- [ ] **A3 — SQL source from config.** Keyset SQL source driven by config (connection
name/string · sql · key · pageSize), materializing columns to `ReportRecord` by
name/ordinal. **Acceptance:** Testcontainers E2E config→SQL→CSV. **Depends on:** A2, A3 reuses v1 keyset.
- [x] **A3 — SQL source from config.** `SqlConfigSourceProvider` (`type: "sql"`) reads
connectionString/sql/key/pageSize from the source properties and materializes
`ReportRecord`s by schema-column name, reusing the v1 keyset engine (an additive internal
materializer overload on `SqlKeysetSource<T>`); `AddSqlConfigSource()` DI helper.
**Acceptance:** Testcontainers E2E config→SQL→CSV. ✅ 6 green SQL integration tests
(4 typed + 2 dynamic). **Depends on:** A2, reuses v1 keyset.
- [ ] **A4 — JsonLogic filter.** Compile a JsonLogic expression to
`Func<ReportRecord,bool>` evaluated on the dynamic row. **Acceptance:** operator
coverage + a filtered E2E. **Depends on:** A1.
Expand Down
13 changes: 7 additions & 6 deletions samples/04-dynamic-config-csv/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ What the JSON drives today:
- the **columns / schema**: name, semantic type, header, format and culture;
- the **outputs** and **destinations** selection by id (`"csv"`, `"local"`).

Standing in for not-yet-built pieces:
This sample keeps an in-memory source so it runs with no database. The real **SQL** config source
now exists (**A3**): register it with `services.AddSqlConfigSource()` and change the `source` section
to `{ "type": "sql", "properties": { "connectionString": "...", "sql": "...", "key": "Id" } }` to read
from SQL Server instead — nothing else in the config changes.

- the **SQL** config source arrives in **A3**, so an in-memory `IConfigSourceProvider`
([`InMemorySalesSourceProvider`](InMemorySalesSourceProvider.cs)) supplies the rows;
- binding format/destination **options** from config arrives later (**A5**), so the CSV and Local
factories are pre-wired in DI — the JSON's `properties` under `outputs`/`destinations` are
illustrative for now.
Still standing in: binding format/destination **options** from config arrives later (**A5**), so the
CSV and Local factories are pre-wired in DI — the JSON's `properties` under `outputs`/`destinations`
are illustrative for now.
29 changes: 29 additions & 0 deletions samples/05-dynamic-config-sql/05-dynamic-config-sql.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\NeoReports.Core\NeoReports.Core.csproj" />
<ProjectReference Include="..\..\src\Sources\NeoReports.Sources.Sql\NeoReports.Sources.Sql.csproj" />
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Csv\NeoReports.Formats.Csv.csproj" />
<ProjectReference Include="..\..\src\Destinations\NeoReports.Destinations.Local\NeoReports.Destinations.Local.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>

<ItemGroup>
<None Update="report.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
56 changes: 56 additions & 0 deletions samples/05-dynamic-config-sql/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NeoReports.Abstractions;
using NeoReports.Core.Configuration;
using NeoReports.Core.Pipeline;
using NeoReports.Destinations.Local;
using NeoReports.Formats.Csv;
using NeoReports.Sources.Sql;

// Sample 05 — config-driven report reading from SQL Server (dynamic path + A3).
//
// The whole report is defined in report.json, with a "sql" source and no typed POCO. Point it at a
// SQL Server that has a Sales(Id BIGINT, Customer NVARCHAR, Amount DECIMAL, Date DATETIME2) table:
//
// dotnet run --project samples/05-dynamic-config-sql -- "<connection-string>"
//
// Compared to sample 01 (the typed SQL report) this reads the exact same data, but the report shape
// lives entirely in JSON. Swapping back to sample 04's in-memory source is just a config change.

var connectionString = args.Length > 0
? args[0]
: "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True";

// Load the config and inject the connection string (JSON-escaped) into the placeholder, so no
// secret is committed to report.json. Path.Join never drops earlier segments.
var configPath = Path.Join(AppContext.BaseDirectory, "report.json");
var json = (await File.ReadAllTextAsync(configPath))
.Replace("\"__CONNECTION_STRING__\"", JsonSerializer.Serialize(connectionString), StringComparison.Ordinal);

var config = new JsonReportConfigParser().Parse(json);

var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
services.AddSqlConfigSource(); // source "sql"
services.AddSingleton<IWriterFactory>(new CsvWriterFactory(new CsvOptions())); // format "csv"
services.AddSingleton<IDestinationFactory>(
new LocalDestinationFactory("./out/{name}-{date:yyyy-MM-dd}.{ext}")); // dest "local"

await using var provider = services.BuildServiceProvider();

var report = ReportConfigCompiler.Compile(config, provider);

var logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("dynamic-sql");
var exec = new ReportExecutionContext(
Guid.NewGuid().ToString("N"), config.Name, parameters: null, logger, CancellationToken.None);

var result = await ReportRunner.ExecuteAsync(report, exec, provider, CancellationToken.None);

Console.WriteLine($"Report: {config.Name}");
Console.WriteLine($"Status: {result.Status}");
Console.WriteLine($"Records read/written: {result.Stats.RecordsRead}/{result.Stats.RecordsWritten}");
foreach (var upload in result.Uploads)
Console.WriteLine($"Uploaded: {upload.RemotePath} (success={upload.Success})");

return result.Status == ReportRunStatus.Failed ? 1 : 0;
23 changes: 23 additions & 0 deletions samples/05-dynamic-config-sql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 05 — dynamic config → SQL Server → CSV

The config-driven path (A2) reading from a real **SQL Server** source (A3). The report is defined
entirely in [`report.json`](report.json) with a `"sql"` source — no typed POCO.

```bash
dotnet run --project samples/05-dynamic-config-sql -- "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True"
# writes ./out/monthly-sales-<date>.csv
```

Expects a `Sales(Id BIGINT, Customer NVARCHAR, Amount DECIMAL, Date DATETIME2)` table (same schema
as sample 01). The connection string passed on the command line is injected into the config's
`__CONNECTION_STRING__` placeholder, so no secret lives in `report.json`.

How it works:

- `services.AddSqlConfigSource()` registers the `"sql"` source provider.
- `JsonReportConfigParser` parses the document; `ReportConfigCompiler` compiles it into the same
runnable report the fluent builder produces.
- The SQL source materializes positional `ReportRecord`s by matching each schema column to the
result-set column by name, reusing the v1 keyset engine (connection-per-page, opaque cursor).

This is sample 04 with one change: the `source` section is `"sql"` instead of `"inmemory"`.
23 changes: 23 additions & 0 deletions samples/05-dynamic-config-sql/report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "monthly-sales",
"pageSize": 1000,
"source": {
"type": "sql",
"properties": {
"connectionString": "__CONNECTION_STRING__",
"sql": "SELECT Id, Customer, Amount, Date FROM Sales WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id",
"key": "Id",
"pageSize": 1000
}
},
"columns": [
{ "name": "Id", "type": "Integer", "displayName": "Sale ID", "nullable": false },
{ "name": "Customer", "type": "String" },
{ "name": "Amount", "type": "Decimal", "displayName": "Amount", "format": "C2", "culture": "pt-BR" },
{ "name": "Date", "type": "DateTime", "displayName": "Sale Date", "format": "yyyy-MM-dd" }
],
"outputs": [ { "format": "csv" } ],
"destinations": [
{ "type": "local", "properties": { "path": "./out/{name}-{date:yyyy-MM-dd}.{ext}" } }
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NeoReports.Abstractions;

namespace NeoReports.Sources.Sql;

/// <summary>DI helpers for the SQL source.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the config-driven SQL source provider (<c>type: "sql"</c>) so reports defined in
/// configuration can read from SQL Server. Safe to call multiple times.
/// </summary>
/// <param name="services">The service collection.</param>
public static IServiceCollection AddSqlConfigSource(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigSourceProvider, SqlConfigSourceProvider>());
return services;
}
}
81 changes: 81 additions & 0 deletions src/Sources/NeoReports.Sources.Sql/SqlConfigSourceProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json;
using NeoReports.Abstractions;

namespace NeoReports.Sources.Sql;

/// <summary>
/// Config-driven SQL source for the dynamic path (<c>type: "sql"</c>). Reads its settings from the
/// source <c>properties</c> (<c>connectionString</c>, <c>sql</c>, <c>key</c>, optional
/// <c>pageSize</c>) and produces an <see cref="IBatchSource{T}"/> of positional
/// <see cref="ReportRecord"/>s. Each row is materialized by reading the result-set column whose name
/// matches each schema column (case-insensitive), reusing the v1 keyset paging engine.
/// </summary>
public sealed class SqlConfigSourceProvider : IConfigSourceProvider
{
/// <inheritdoc />
public string Type => "sql";

/// <inheritdoc />
public IBatchSource<ReportRecord> Create(SourceConfig source, ReportSchema schema, IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(schema);

IReadOnlyDictionary<string, object?>? properties = source.Properties;
string connectionString = RequireString(properties, "connectionString");
string sql = RequireString(properties, "sql");
string key = RequireString(properties, "key");
int pageSize = OptionalInt(properties, "pageSize") ?? 1000;

return new SqlKeysetSource<ReportRecord>(
connectionString, sql, key, pageSize, schema,
parameters: null,
materialize: (reader, ordinals) => Materialize(reader, ordinals, schema));
}

private static ReportRecord Materialize(
DbDataReader reader, IReadOnlyDictionary<string, int> ordinalByName, ReportSchema schema)
{
var values = new object?[schema.Count];
for (var i = 0; i < schema.Count; i++)
{
values[i] = ordinalByName.TryGetValue(schema.Columns[i].Name, out int ordinal) && !reader.IsDBNull(ordinal)
? reader.GetValue(ordinal)
: null;
}

return new ReportRecord(schema, values);
}

private static string RequireString(IReadOnlyDictionary<string, object?>? properties, string key)
{
if (properties is not null
&& properties.TryGetValue(key, out var value)
&& value is string text
&& !string.IsNullOrWhiteSpace(text))
{
return text;
}

throw new ConfigurationException($"The SQL source requires a non-empty '{key}' property.");
}

private static int? OptionalInt(IReadOnlyDictionary<string, object?>? properties, string key)
{
if (properties is null || !properties.TryGetValue(key, out var value) || value is null)
return null;

return value switch
{
int i => i,
long l => checked((int)l),
double d => (int)d,
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } e => e.GetInt32(),
_ => throw new ConfigurationException(
$"The SQL source property '{key}' must be an integer (was {value.GetType().Name})."),
};
}
}
22 changes: 20 additions & 2 deletions src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
private readonly string _keyColumn;
private readonly int _pageSize;
private readonly IReadOnlyDictionary<string, object?> _parameters;
private readonly RecordMaterializer<T> _materializer = new();
private readonly Func<DbDataReader, IReadOnlyDictionary<string, int>, T> _materialize;

/// <summary>Creates the source.</summary>
/// <param name="connectionString">SQL Server connection string.</param>
Expand All @@ -36,6 +36,23 @@
int pageSize,
ReportSchema schema,
IReadOnlyDictionary<string, object?>? parameters = null)
: this(connectionString, sql, keyColumn, pageSize, schema, parameters, materialize: null)
{
}

/// <summary>
/// Creates the source with a custom row materializer. Used by the dynamic path to materialize a
/// positional <c>ReportRecord</c> by schema name; when <paramref name="materialize"/> is null the
/// reflection-based <see cref="RecordMaterializer{T}"/> (typed POCO) is used.
/// </summary>
internal SqlKeysetSource(
string connectionString,
string sql,
string keyColumn,
int pageSize,
ReportSchema schema,
IReadOnlyDictionary<string, object?>? parameters,
Func<DbDataReader, IReadOnlyDictionary<string, int>, T>? materialize)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_sql = sql ?? throw new ArgumentNullException(nameof(sql));
Expand All @@ -44,6 +61,7 @@
_pageSize = pageSize;
Schema = schema ?? throw new ArgumentNullException(nameof(schema));
_parameters = parameters ?? new Dictionary<string, object?>();
_materialize = materialize ?? new RecordMaterializer<T>().Materialize;
}

/// <inheritdoc />
Expand Down Expand Up @@ -86,14 +104,14 @@

while (read < _pageSize && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
records.Add(_materializer.Materialize(reader, ordinals));
records.Add(_materialize(reader, ordinals));
if (keyOrdinal >= 0 && !reader.IsDBNull(keyOrdinal))

Check warning on line 108 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Await IsDBNullAsync instead.

Check warning on line 108 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Await IsDBNullAsync instead.

Check warning on line 108 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Await IsDBNullAsync instead.

Check warning on line 108 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Await IsDBNullAsync instead.
lastKey = Convert.ToString(reader.GetValue(keyOrdinal), CultureInfo.InvariantCulture);
read++;
}

var hasMore = records.Count == _pageSize && lastKey is not null;
var nextCursor = hasMore && lastKey is not null ? EncodeCursor(lastKey) : null;

Check warning on line 114 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Change this condition so that it does not always evaluate to 'True'.

Check warning on line 114 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Change this condition so that it does not always evaluate to 'True'.

Check warning on line 114 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Change this condition so that it does not always evaluate to 'True'.

Check warning on line 114 in src/Sources/NeoReports.Sources.Sql/SqlKeysetSource.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Change this condition so that it does not always evaluate to 'True'.
return new BatchResult<T>(records, nextCursor, hasMore);
}

Expand Down
Loading