In this video, I build a report that can run on multiple tables, defined at runtime. Very convenient when you want to perform the same process on multiple tables, such as running with both posted and unposted data, or perhaps on both customers and vendors. Check out the video:

In this video, Erik walks through building what he calls an “Omni Report” in Business Central — a report that isn’t locked into a single table at design time. Instead, the user selects which table to run against at runtime, applies filters dynamically, and then processes or prints the results. This technique is useful when you need a single report object that can operate on customers, vendors, sales documents, or any other table depending on the user’s choice.
The Problem: Reports Are Normally Locked to a Data Set
When you create a standard report in AL, you define the dataset with specific data items tied to specific tables. That’s fine for most scenarios, but sometimes you need more flexibility. Erik encountered this challenge twice in one week — situations where a user needed to select a table at runtime and then process whatever records matched their filters.
Use cases include:
- Running the same processing logic on both unposted and posted sales documents
- Operating on both customers and vendors with the same report
- Building generic data tools that work across any table in the system
Starting with a Blank Report
Erik begins by creating a new report and initially sets it to ProcessingOnly = true — essentially a batch job. A processing-only report has no visual output; it just iterates through data and performs actions. Even with an empty dataset, the report is valid and can be run — it just doesn’t do anything yet.
Letting the User Select a Table
The first step is adding a request page where the user can specify which table to run against. This is done with a simple integer field for the table number:
requestpage
{
layout
{
area(Content)
{
field(TableNo; TableNo)
{
trigger OnValidate()
begin
Ref.Open(TableNo);
RecCount := Ref.Count();
end;
}
}
}
}
var
Ref: RecordRef;
TableNo: Integer;
When the user enters a table number (e.g., 18 for Customer, 23 for Vendor), the OnValidate trigger opens a RecordRef on that table. The RecordRef is the key to this whole approach — it’s a generic reference to any table, determined at runtime rather than compile time.
Adding Dynamic Filters with FilterPageBuilder
Knowing which table to use isn’t enough — the user also needs to filter records. Rather than asking users to type raw filter strings, Erik uses the FilterPageBuilder to present a proper filter dialog:
field(ViewString; ViewString)
{
trigger OnAssistEdit()
var
Builder: FilterPageBuilder;
v: Variant;
begin
Builder.PageCaption := 'Select ' + Ref.Name;
v := Ref;
Builder.AddRecord(Ref.Name, v);
if ViewString <> '' then
Builder.SetView(Ref.Name, ViewString);
if Builder.RunModal() then begin
ViewString := Builder.GetView(Ref.Name, true).Replace('VERSION(1) ', '');
Ref.SetView(ViewString);
RecCount := Ref.Count();
end;
end;
}
The Variant Trick
There’s an interesting quirk here. The AddRecord method expects a parameter of type “Table,” but a RecordRef is not a “Table” — the compiler won’t accept it directly. The workaround is to assign the RecordRef to a Variant first. A Variant is accepted where a “Table” type is expected, even though a RecordRef is not. It’s a bit of a quirk in AL’s type system, but it works reliably.
Handling the View String
The GetView method returns a string representation of the applied filters. Erik uses the named version (passing true) for readability, though he notes that the unnamed version (using field numbers instead of field names) is safer in some edge cases. He also strips out the VERSION(1) prefix that Business Central includes in view strings, since it’s just noise for the user:
ViewString := Builder.GetView(Ref.Name, true).Replace('VERSION(1) ', '');
Showing the Record Count
A nice usability touch is displaying how many records match the current filters:
field(RecCountControl; RecCount)
{
Caption = 'Selected Records';
Editable = false;
}
This updates whenever the user changes the table or applies new filters, giving immediate feedback — for example, “21 customers” before filtering, then “5 customers” after applying a “Balance <> 0″ filter.
Processing the Records: Two Approaches
Option 1: Processing Only (No Dataset)
If you don’t need to print or export, you can skip the dataset entirely and do all the work in the OnPreReport trigger:
trigger OnPreReport()
begin
if Ref.FindSet() then
repeat
// Do your processing here
message('%1', Ref.Field(1).Value);
until Ref.Next() = 0;
end;
This is the simplest approach — a straightforward loop over the filtered records using the RecordRef. Erik demonstrates this by showing how the same code outputs customer primary keys when run on table 18, and vendor primary keys when run on table 23.
Option 2: Using the Dataset (For Printing/Export)
If you need actual report output — printing, PDF, Excel export — you need a dataset. Since you can’t bind a data item to a RecordRef at design time, Erik uses the Integer table as a driver:
dataset
{
dataitem(Records; Integer)
{
column(c1; format(Ref.Field(1).Value))
{ }
column(c2; format(Ref.Field(2).Value))
{ }
trigger OnPreDataItem()
begin
Records.setrange(Number, 1, RecCount);
end;
trigger OnAfterGetRecord()
begin
if Records.Number = 1 then
Ref.FindSet()
else
Ref.Next();
end;
}
}
Here’s how it works:
- OnPreDataItem: The Integer table is filtered to only iterate from 1 to
RecCount(the number of matching records). Without this, the Integer table would try to iterate from negative two billion to positive two billion. - OnAfterGetRecord: On the first iteration (Number = 1), it calls
FindSet()to position theRecordRefon the first record. On subsequent iterations, it callsNext()to advance to the next record. This effectively synchronizes the Integer-driven loop with theRecordRefiteration. - Columns: The columns use
format(Ref.Field(n).Value)to extract values. SinceField().Valuereturns a generic (joker) type that can’t be used directly as a column, wrapping it informat()converts everything to text — which the report engine accepts.
The Complete Source Code
report 57100 "OmniReport"
{
Caption = 'Omni Report';
//ProcessingOnly = true;
ApplicationArea = all;
dataset
{
dataitem(Records; Integer)
{
column(c1; format(Ref.Field(1).Value))
{ }
column(c2; format(Ref.Field(2).Value))
{ }
trigger OnPreDataItem()
begin
Records.setrange(Number, 1, RecCount);
end;
trigger OnAfterGetRecord()
begin
if Records.Number = 1 then
Ref.FindSet()
else
Ref.Next();
end;
}
}
requestpage
{
layout
{
area(Content)
{
field(TableNo; TableNo)
{
trigger OnValidate()
begin
Ref.Open(TableNo);
RecCount := Ref.Count();
end;
}
field(ViewString; ViewString)
{
trigger OnAssistEdit()
var
Builder: FilterPageBuilder;
v: Variant;
begin
Builder.PageCaption := 'Select ' + Ref.Name;
v := Ref;
Builder.AddRecord(Ref.Name, v);
if ViewString <> '' then
Builder.SetView(Ref.Name, ViewString);
if Builder.RunModal() then begin
ViewString := Builder.GetView(Ref.Name, true).Replace('VERSION(1) ', '');
Ref.SetView(ViewString);
RecCount := Ref.Count();
end;
end;
}
field(RecCountControl; RecCount)
{
Caption = 'Selected Records';
Editable = false;
}
}
}
}
var
Ref: RecordRef;
RecCount: Integer;
TableNo: Integer;
ViewString: Text;
}
Key Takeaways
- RecordRef is the foundation — it allows you to work with any table dynamically at runtime without knowing the table at compile time.
- FilterPageBuilder provides a native Business Central filter experience for dynamically chosen tables, so users get the same familiar interface they’re used to.
- The Variant trick bridges the gap between
RecordRefand methods that expect a “Table” type parameter. - Using the Integer table as a dataset driver lets you produce printable/exportable report output from dynamically determined data.
- Wrapping field values in
format()converts the generic joker type to text, making it usable in report columns. - The view string captures the complete filter state, and stripping
VERSION(1)keeps it clean for display.
This “Omni Report” pattern is a powerful technique for building flexible, table-agnostic processing and reporting tools in Business Central. While it’s admittedly an unconventional approach, it solves real problems where you need a single report object to operate across multiple tables based on runtime decisions.