Run a report against your data
ReportProcessor runs a report definition against data you supply. It handles parameters, datasets, calculated fields, filters, sorting, grouping, aggregates and variables. The result is a ReportInstance: the report bound to its data, not yet divided into pages.
This guide uses the DocWright.Reporting and DocWright.Reporting.Rdl packages. The next steps are Paginate and render a report and Export a report. For a complete run of Microsoft's own sample report, see the tutorial Run an SSRS report and export it.
The sample report
All three reporting guides use sales.rdl. It contains:
- a
Salesdataset withRegion,CityandAmountfields, a calculatedLabelfield, and a filter driven by theRegionparameter ("*"means every region); - a
Regionsdataset that supplies theRegionparameter's valid values; - a
MinAmountparameter that is passed to theSalesquery; - a
Titlevariable; - a table grouped by region, where each region's row shows or hides its stores (drill-down), and an Amount column header that the reader can click to sort.
Supply the data
DocWright never connects to a database or runs a report's query. You supply the data for each dataset, by name:
using DocWright.Reporting;
using DocWright.Reporting.Data;
using DocWright.Reporting.Rdl;
/// <summary>The sample's report definition and its data: 3 regions with 20 stores each.</summary>
internal static class SalesData
{
public static ReportDefinition LoadDefinition()
{
using FileStream file = File.OpenRead("sales.rdl");
return new RdlReader().Read(file);
}
public static ReportDataTable Sales()
{
// Columns are typed. A value of the wrong type fails here, at AddRow, not mid-run.
var table = new ReportDataTable()
.AddColumn("Region", typeof(string))
.AddColumn("City", typeof(string))
.AddColumn("Amount", typeof(decimal));
string[] regions = ["North", "Central", "South"];
for (int r = 0; r < regions.Length; r++)
{
for (int i = 1; i <= 20; i++)
{
decimal? amount = i == 7 && r == 2 ? null : 500m + ((i * 37 + r * 101) % 23) * 150m;
table.AddRow(regions[r], $"{regions[r]} store {i:00}", amount);
}
}
return table;
}
// Objects work too: columns come from selectors, not reflection, so this survives trimming.
public static ReportObjectData<(string Code, string Name)> Regions() =>
new ReportObjectData<(string Code, string Name)>(
[("*", "All regions"), ("North", "North"), ("Central", "Central"), ("South", "South")])
.Column("Code", r => r.Code)
.Column("Name", r => r.Name);
// The provider matches datasets by exact name. The in-memory provider ignores the query text.
public static ReportInMemoryDataProvider Provider() =>
new ReportInMemoryDataProvider()
.Add("Sales", Sales())
.Add("Regions", Regions());
}
ReportDataTablehas typed columns and checks each value as you add it. A value of the wrong type throws atAddRow, not halfway through a run.nullandDBNull.Valueare both stored as null.ReportObjectData<T>reads a sequence of your own objects. Each column comes from a selector instead of reflection, so it keeps working after trimming and with native AOT, and renaming a property becomes a compile error. The sequence is read once per run.ReportInMemoryDataProviderreturns the data you added under each dataset's name. Names are case-sensitive. It ignores the query text: theWHERE Amount >= @Mininsales.rdlhas no effect. A dataset's<Filter>elements are applied by DocWright, though.
Parameters
using DocWright.Reporting.Expressions;
using DocWright.Reporting.Processing;
var run = new ReportRunOptions { ExecutionTime = new DateTime(2026, 10, 1, 9, 0, 0) };
// What a parameter bar needs. Runs only the datasets the parameters read.
foreach (ReportParameterPrompt prompt in ReportProcessor.GetParameters(definition, provider, run))
{
string valid = prompt.ValidValues is null ? "any" : string.Join(", ", prompt.ValidValues.Select(v => $"{v.Value}={v.Label}"));
Console.WriteLine($"{prompt.Name,-10} {prompt.DataType,-7} {prompt.State,-18} values=[{string.Join(", ", prompt.Values)}] valid=[{valid}]");
}
run.Parameters["Region"] = new ReportParameterValue("*");
Output (all output from this page's samples):
Region String MissingValidValue values=[] valid=[*=All regions, North=North, Central=Central, South=South]
MinAmount Float HasValidValue values=[0] valid=[any]
Sales: All regions
60
129250
North store 01 (North)
Sales: South: 20 rows, sum 42050
GetParameters:
query Regions on Shop: SELECT Code, Name FROM Regions []
Process:
query Regions on Shop: SELECT Code, Name FROM Regions []
query Sales on Shop: SELECT Region, City, Amount FROM Sales WHERE Amount >= @Min [@Min=0]
MissingValidValue, rejected: True
DXP9203: The report parameter 'Region' has no valid value (MissingValidValue): A value of the report parameter 'Region' is not one of its valid values. Every parameter needs a valid value before the report can run.
en-US: "1,5" -> 15
de-DE: "1,5" -> 1.5
rows: 0
DXP9201 Error: The data provider supplied no data for the dataset 'Sales'; everything bound to it is processed as empty.
strict: DXP9201
MaxReportDataSetRows: The dataset 'Sales' returned more than 10000 rows.
cancelled
South, by amount descending: 3,800, 3,650, 3,500, 3,350, 3,050, ...
GetParameters doesn't run the report. It reads only the datasets the parameters need, and returns everything a parameter bar has to show: Prompt, DataType, the current Values and Labels, ValidValues, Nullable, AllowBlank, MultiValue, Hidden, Dependencies and Dependents, and a State:
State |
Meaning |
|---|---|
HasValidValue |
The parameter is ready. |
MissingValidValue |
It has no value, or its value isn't valid. Ask the user for one. |
HasOutstandingDependencies |
A parameter it depends on doesn't have a valid value yet. |
DynamicValuesUnavailable |
Its default or valid values come from a dataset the provider didn't supply. |
Processing is stateless. When the user changes a value, call GetParameters again with every value you have. new ReportParameterValue("W", "West") sets the label as well as the value. Without a label, it's looked up in the valid values.
Run it
using DocWright.Reporting.Processing;
using (ReportInstance instance = ReportProcessor.Process(definition, provider, run))
{
// Evaluate any expression at report scope, over the processed data.
Console.WriteLine(instance.Evaluate("=Variables!Title.Value"));
Console.WriteLine(instance.Evaluate("=CountRows(\"Sales\")"));
Console.WriteLine(instance.Evaluate("=Sum(Fields!Amount.Value, \"Sales\")"));
Console.WriteLine(instance.Evaluate("=First(Fields!Label.Value, \"Sales\")"));
}
// Change a parameter and run again. Processing is stateless.
run.Parameters["Region"] = new ReportParameterValue("South");
using (ReportInstance south = ReportProcessor.Process(definition, provider, run))
{
Console.WriteLine($"{south.Evaluate("=Variables!Title.Value")}: {south.Evaluate("=CountRows(\"Sales\")")} rows, "
+ $"sum {south.Evaluate("=Sum(Fields!Amount.Value, \"Sales\")")}");
}
Evaluate runs any expression at report scope, outside every data region. That's useful for totals in an email or for checking a run in a test. The instance also exposes the Parameters it used, its Culture (the report's <Language>, or en-US) and its Diagnostics. Dispose it to release the rows it holds.
ExecutionTime is what Globals!ExecutionTime and Now() return. If you leave it null, those expressions are refused. The clock is never read, which is what makes runs reproducible.
Your own data provider
To read from a database, a web service or files, subclass ReportDataProvider. It has one method:
using DocWright.Reporting.Data;
/// <summary>Logs each query, then delegates. A real provider would run the query against a database.</summary>
internal sealed class LoggingProvider(ReportDataProvider inner) : ReportDataProvider
{
public override ReportDataReader? ExecuteQuery(ReportDataQuery query, CancellationToken cancellationToken)
{
string parameters = string.Join(", ", query.Parameters.Select(p => $"{p.Name}={p.Value}"));
Console.WriteLine($" query {query.DataSetName} on {query.DataSourceName}: {query.CommandText} [{parameters}]");
return inner.ExecuteQuery(query, cancellationToken);
}
}
using DocWright.Reporting.Processing;
Console.WriteLine("GetParameters:");
ReportProcessor.GetParameters(definition, new LoggingProvider(provider), run);
Console.WriteLine("Process:");
using (ReportProcessor.Process(definition, new LoggingProvider(provider), run))
{
}
Before calling your provider, the processor evaluates each dataset's command text, query parameters and connection string, so query.Parameters holds real values (@Min=0 in the output). ExecuteQuery is called at most once per dataset per run. Return a reader, either from ReportDataTable.CreateReader() or your own ReportDataReader, and the processor disposes it. Return null if you have no data for that dataset. A provider shared between concurrent runs must be thread-safe.
Invalid values and the user's culture
using System.Globalization;
using DocWright.Reporting.Expressions;
using DocWright.Reporting.Processing;
// A value that isn't one of the valid values comes back rejected, and Process refuses to run.
var stale = new ReportRunOptions();
stale.Parameters["Region"] = new ReportParameterValue("West");
ReportParameterPrompt region = ReportProcessor.GetParameters(definition, provider, stale)[0];
Console.WriteLine($"{region.State}, rejected: {region.SuppliedValueRejected}");
try
{
ReportProcessor.Process(definition, provider, stale);
}
catch (ReportProcessingException error)
{
Console.WriteLine($"{error.DiagnosticCode}: {error.Message}");
}
// Text values are parsed in the user's culture (ReportRunOptions.Culture, en-US by default).
foreach (string culture in new[] { "en-US", "de-DE" })
{
var options = new ReportRunOptions { Culture = CultureInfo.GetCultureInfo(culture) };
options.Parameters["MinAmount"] = new ReportParameterValue("1,5");
ReportParameterPrompt minimum = ReportProcessor.GetParameters(definition, provider, options)[1];
Console.WriteLine($"{culture}: \"1,5\" -> {minimum.Values[0]}");
}
- A supplied value that isn't valid comes back with
SuppliedValueRejectedset and no value.Processrefuses to run while any parameter lacks a valid value, and throwsReportProcessingExceptionwith codeDXP9203. - Text values are parsed in
ReportRunOptions.Culture, the user's culture (en-US by default). That's why"1,5"is fifteen in en-US and one and a half in de-DE. Expressions are always evaluated in the report's own<Language>. Supply a value of the parameter's own type, such asnew ReportParameterValue(1.5), and nothing is parsed.
Diagnostics and strict mode
using DocWright.Core.Diagnostics;
using DocWright.Reporting.Data;
using DocWright.Reporting.Expressions;
using DocWright.Reporting.Processing;
// A dataset the provider has no data for is reported and processed as empty...
var diagnostics = new CollectingConversionDiagnostics();
var partial = new ReportRunOptions { Diagnostics = diagnostics };
partial.Parameters["Region"] = new ReportParameterValue("*");
var onlyRegions = new ReportInMemoryDataProvider().Add("Regions", SalesData.Regions());
using (ReportInstance instance = ReportProcessor.Process(definition, onlyRegions, partial))
{
Console.WriteLine($"rows: {instance.Evaluate("=CountRows(\"Sales\")")}");
}
foreach (ConversionDiagnostic diagnostic in diagnostics.Snapshot())
{
Console.WriteLine($"{diagnostic.Code} {diagnostic.Severity}: {diagnostic.Message}");
}
// ...unless StrictMode is on, which turns every reported error into an exception.
partial.StrictMode = true;
try
{
ReportProcessor.Process(definition, onlyRegions, partial);
}
catch (ReportProcessingException error)
{
Console.WriteLine($"strict: {error.DiagnosticCode}");
}
By default, a problem is reported through ReportRunOptions.Diagnostics and whatever it affects is left empty. Examples are a dataset with no data (DXP9201), a filter that compares a number with text, or a failing group expression. StrictMode turns every reported error into a ReportProcessingException. Three problems stop the run in either mode: a parameter with no valid value (DXP9203), an error in a parameter's definition (DXP9203, DXP9204), and a provider whose data doesn't match its own declared columns (DXP9202).
Limits and cancellation
using DocWright.Core;
using DocWright.Reporting.Data;
using DocWright.Reporting.Expressions;
using DocWright.Reporting.Processing;
static IEnumerable<int> Forever()
{
for (int i = 0; ; i++)
{
yield return i;
}
}
var endless = new ReportObjectData<int>(Forever())
.Column("Region", i => "North")
.Column("City", i => $"store {i}")
.Column("Amount", i => (decimal?)i);
var bounded = new ReportRunOptions();
bounded.Parameters["Region"] = new ReportParameterValue("*");
bounded.Limits.MaxReportDataSetRows = 10_000;
try
{
var huge = new ReportInMemoryDataProvider().Add("Sales", endless).Add("Regions", SalesData.Regions());
ReportProcessor.Process(definition, huge, bounded);
}
catch (ResourceLimitExceededException error)
{
Console.WriteLine($"{error.LimitName}: {error.Message}");
}
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
try
{
ReportProcessor.Process(definition, provider, run, cancellation.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("cancelled");
}
Limit (ReportRunOptions.Limits) |
Default | Caps |
|---|---|---|
MaxReportDataSetRows |
2,000,000 | The rows one dataset may return. It is also the memory limit, because rows are held for the whole run so they can be sorted. |
MaxReportGroupInstances |
4,000,000 | The group instances in one run. |
MaxReportInstanceNodes |
10,000,000 | The size of the processed report tree. |
All three are on by default. Exceeding one throws ResourceLimitExceededException, which is never turned into a diagnostic. Cancellation is checked between rows, groups and items.
Interactive sort
using DocWright.Fonts;
using DocWright.Reporting.Pagination;
using DocWright.Reporting.Processing;
// A textbox with <UserSort> is a clickable column header. The click becomes a request
// naming that textbox, and the report is processed again with the sort applied.
var sorted = new ReportRunOptions { ExecutionTime = run.ExecutionTime };
sorted.Parameters["Region"] = new ReportParameterValue("South");
sorted.InteractiveSorts.Add(new ReportInteractiveSort("AmountHeader", ReportSortDirection.Descending));
var fonts = new FontEngine(); // reads every installed font: build one and keep it
using (ReportInstance instance = ReportProcessor.Process(definition, provider, sorted))
{
ReportPageModel pages = ReportPaginator.Paginate(instance, fonts);
IEnumerable<ReportPageTextbox> amounts = Flatten(pages.Pages[0].Items)
.OfType<ReportPageTextbox>().Where(t => t.Name == "AmountBox");
Console.WriteLine("South, by amount descending: " + string.Join(", ", amounts.Take(5).Select(t => t.Value)) + ", ...");
}
static IEnumerable<ReportPageItem> Flatten(IEnumerable<ReportPageItem> items) =>
items.SelectMany(i => new[] { i }.Concat(Flatten(i.Children)));
A textbox with <UserSort> is a column header a viewer can click. Each click is a request in ReportRunOptions.InteractiveSorts that names the textbox. The textbox's UserSort already says what to sort by and where. In sales.rdl the Amount header's sort is scoped to the Rows group, so the request replaces that group's authored sort by store name. The store with no amount comes last when sorting descending, as it does in Reporting Services.
A request that can't be applied is reported as DXP9504 and the authored order is kept. Two requests for the same scope make a two-key sort, like Shift+click.
Next steps
- Paginate and render a report: pages, drill-down, the document map, and PDF or PNG output.
- Export a report to Word, Excel, CSV or text.
- Report expressions for the expression language.