Archive for January, 2008

Full End-User Reporting with ReportBuilder and DataAbstract

Since a lot of People have been asking about a successful integration of digital metaphor’s wonderful Reportbuilder with RemObjects’ DataAbstract I thought it might be a good idea to post what I have been doing to integrate these two in an elegant fashion.

Objective

  • Give the end-user access to the full spectrum of possibilities, including design of queries, Report-Designer Preview, Report Explorer and so on.
  • When running reports within you application server, make use of direct database access for increased speed.

Approach

  • Write a DADE plug-in (data driver) that abstracts where the report is actually executed
  • On the client-side: Use a locally available Channel and Message to access a ReportService on the server-side which allows arbitrary SQL to be executed. This has already been solved by Wouter Devos of XLent Solutions and I will extend his solution.
  • On the server-side: Access the active service the report is executed within and execute sql generated by ReportBuilder’s DADE directly without the overhead of a network round-trip

Caveats

The whole point of a multi-tier setup is to abstract the fact that there is a database from the client, it should not send or know about sql in any case. By allowing the execution of arbitrary SQL by the client you give blank access to all information. To minimise the potential security risk, the associated transaction should be read-only to prevent any updates by the client. Additionally you should specify a security token that is only granted to selected users (“End-User Reporting Allowed”).

Because all data that is being retrieved is re-packaged and transmitted over the wire ultimately ending up in a memory dataset the resulting speed is definitely slower than direct database access and you should likely limit the number of records returned to a reasonable number, so the client does not “hang” while waiting for the whole database to be pulled to local memory.

Technical Details

Client-Side

On the client implement one interface in a singleton (your hydra host would be a good candidate) and assign the global variable in unit daDataAbstract.pas.

  { Implement this interface on the client. Assumptions:
    - Single thread on the client (UI)
    - One connection to one server
    - The Remoteservice.Message.ClientId is implicitly used for session management

    set global variable gReportingClientController on the client
  }

  IReportingClientController = interface
  ['{9D6908F8-BB64-400F-BE94-0CF550EC2BA2}']
    function GetRemoteService: TRORemoteService;
    function GetDataStreamer: TDADataStreamer;
  end;

This will provide the minimal means to connect to your server

Server-side

On the server-side you obviously need to implement a service that hosts a TppReport Component and gives necessary information to the client. This service must implement the following interface

   { Implement this interface in the Service that contains the Report component.
    When retrieving data on the server, Report.Owner is querie'd for this
    interface. Fall-back to local service discovery (FindClassFactory), and
    instantiate the reporting service (slower than the former).
    }

  IReportingServiceRBuilderSupport = interface
  ['{AFF049E4-5B72-430F-9973-33CF4DC53B6E}']
    function GetConnection: IDAConnection;
    function GetDataStreamer: TDADataStreamer;
    procedure GetTableNames(out TableNames: string);
  end;

This interface is used when executing reports “locally” within the server context. The class factory for the reporting service should be a pooled one, since we want to utilize the positive effects of full multi-threading.

The service needs to implement a few additional methods, so the client can gather the necessary information and could look like this

  { TReportingService }
  TReportingService = class(TRORemoteDataModule, IReportingService,
                            IReportingServiceRBuilderSupport
                      )
    BinDataStreamer: TDABinDataStreamer;
    bpReportFolders: TDABusinessProcessor;
    bpReportItems: TDABusinessProcessor;
    ppReport: TppReport;
    ppReportItemWithData: TppDBPipeline;
    tblReportItemWithData: TDACDSDataTable;
    dsReportItemWithData: TDADataSource;
    Schema: TDASchema;
    procedure RORemoteDataModuleActivate(const aClientID: TGUID;
      aSession: TROSession; const aMessage: IROMessage);
    procedure RORemoteDataModuleDeactivate(const aClientID: TGUID;
      aSession: TROSession);
  private
    FConnection: IDAConnection;
  protected
    { IReportingService methods }
    // Needed for report explorer only
    function GetAllReportItems: Binary;
    function GetReportTemplateData(const ReportItemId: Integer): Binary;
    function UpdateReports(const Data: TROBinaryMemoryStream): TROBinaryMemoryStream;
  public
    constructor Create(AOwner: TComponent); override;

    //Called by client-side reporting
    function GetReportDatasetData(const SQL: string; const TableName: string;
      const LoadAll: Boolean): TROBinaryMemoryStream;
    function GetReportDatasetSchema(const SQL: string;
      const TableName: string): TROBinaryMemoryStream;

    // Only needed if the client should use human readable translated reporting meta-data
    function GetDictionaryData: TROBinaryMemoryStream;

    // Custom method to execute a certain report on the server-side
    function GenerateReportPreview(const ReportItemId: Integer;
      const Params: TReportParamList): TROBinaryMemoryStream;

    // IReportingServiceRBuilderSupport
    function GetConnection: IDAConnection;
    function GetDataStreamer: TDADataStreamer;
    procedure GetTableNames(out TableNames: string);
  end;

The most important part of the implementation is

function TReportingService.GetReportDatasetSchema(const SQL,
  TableName: string): TROBinaryMemoryStream;
var
  ds: IDADataset;
begin
  Result := Binary.Create();
  BinDataStreamer.Initialize(Result, aiWrite);
  try
    if SQL = '' then
      ds := FConnection.NewDataset(Format('SELECT * FROM %s WHERE 1 = 0',
                                   [TableName]), 'dsReportData')
    else
      ds := FConnection.NewDataset(SQL, 'dsReportData');

    ds.Prepared := True;
    BinDataStreamer.WriteDataset(ds, [woSchema]);
  finally
    BinDataStreamer.Finalize;
  end;
end;

function TReportingService.GetReportDatasetData(const SQL, TableName: string;
  const LoadAll: Boolean): TROBinaryMemoryStream;
var
  ds: IDADataset;
begin
  Result := Binary.Create();
  BinDataStreamer.Initialize(Result, aiWrite);
  try
    if SQL = '' then
      ds := FConnection.NewDataset(Format('SELECT * FROM %s WHERE 1 = 0',
                                          [TableName]), 'dsReportData')
    else
      ds := FConnection.NewDataset(SQL, 'dsReportData');

    ds.Open;

    if LoadAll then
      BinDataStreamer.WriteDataset(ds, [woSchema, woRows], -1)
    else
      BinDataStreamer.WriteDataset(ds, [woSchema, woRows], 0);
  finally
    BinDataStreamer.Finalize;
  end;
end;

procedure TReportingService.GetTableNames(out TableNames: string);
var
  Tables: IROStrings;
begin
  FConnection.GetTableNames(Tables);
  TableNames := Tables.CommaText;
end;

Summary

This article is just a brief overview of what is actually needed to get a fully integrated reporting solution and there are a lot of nuts and bolts involved in the full solution that I will not show here. I hope this solution will help a lot of people who just want to get something working.

Download link: DADE-Plugin for DataAbstract