tiOPF
Free, Open Source Object Persistence Framework for
Free Pascal & Delphi

Persisting and unit testing a simple collection

Introduction

In this paper we shall build a simple object based application that can save its data to a variety database formats. We shall develop the object model and persistence code then write the necessary unit tests using DUnit2. Finally we shall implement and unit test the business rules and build a simple user interface.

The finished results shall look like this:



The example application we shall build

We shall build a simple address-book application that stores names, addresses and phone numbers for people. A simplified class diagram is shown below:



Each person may have 0 to many addresses and 0 to many e-addresses (phone, fax, mobile, email, skype, website)

We shall develop the business object model (or BOM) and unit test each object and object-relationship as we go. This shall be done against a Firebird database. When the BOM is complete we shall rough-out a simple VCL forms based user interface. Finally, we shall extend the application to persist data to both XML and over the internet through an application server.

Creating the directory structure

To get started, we need a project directory structure to hold the following:
Here’s the starting directory structure I use:



The BOM directory will hold all non-gui code (The address book classes and their persistence code). The GUI directory shall hold the user interface code. The DUnit directory shall hold a GUI based test application, then later a text based test app that will form part of the automated build process.

Rough out the projects

Create a project group called AdrsBook_ProjectGroup and save it to the root.

Create a new VCL Forms application (removing the auto-created main form) and save it as DUnit\DUnitAdrsBookGUI.

In Project | Options, set
Create the following empty PAS files:
Stub out an empty unit test in DUnit\Person_TST.pas like this:

unit Person_TST;

interface

uses
  tiTestFramework;

type

  TPersonTestCase = class(TtiTestCase)
  published
    procedure PersonList_Read;
  end;

implementation
uses
  TestFramework;

{ TPersonTestCase }
procedure TPersonTestCase.PersonList_Read;
begin
  Assert(False, 'Under construction');
end;

initialization
  RegisterTest(TPersonTestCase.Suite);

end.
Note we use TtiTestCase as the parent for TPersonTestCase as we will be accessing some specific tiOPF test methods.

DUnitAdrsBookGUI.dpr looks as you would expect:

program DUnitAdrsBookGUI;

uses
  FastMM4,
  GUITestRunner,
  Person_TST in 'Person_TST.pas',
  Person_BOM in '..\BOM\Person_BOM.pas';

{$R *.res}

begin
  GUITestRunner.RunRegisteredTests;

end.
Executing DUnitAdrsBookGUI will bring up the following main form showing three new buttons ‘Leak detection’, ‘Warnings’, ‘Tests without Check called’ and ‘Summary level testing’:



These can be toggled to give more information about each test as it executes.

Connecting to a database

To create an empty database, run the scripts found in the demos directory here:

tiOPF2\Demos\Demo_18_AdrsBook\DatabaseCreateScripts

A fragment of this script is shown below:

Create Database 'Adrs.fdb' user 'SYSDBA' password 'masterkey';

connect  "Adrs.fdb" user "SYSDBA" password "masterkey" ;

create table person
  (
oid              
varchar(36)    not null,
    first_name varchar(60),
last_name      varchar(60),
title          varchar(10),
initials       varchar(10),
  );

alter table person add
constraint person_pk
primary key (oid);
Connecting a tiOPF application to a database is a two step process:
To register a persistence layer, simply add the appropriate tiQueryXXX.pas unit to your project. For example, to register Firebird via the Interbase Express components that come with Delphi, add the unit tiQueryIBX.pas.

To connect to a database, use the tiOPFManager.ConnectDatabase() method:
uses
  …
  tiQueryIBX,
  tiOPFManager,
  …

  GTIOPFManager.ConnectDatabase('/path/to/database/adrs.fdb', 'SYSDBA', 'masterkey');
Run the unit test app again to confirm the database connection works.

Roughing out the business objects

The abstract classes used for building a business object hierarchy are TtiObject and TtiObjectList. TtiObject introduces the necessary methods and properties for persistence. TtiObjectList descends from TtiObject and adds list management methods. TtiObject and TtiObjectList are an implementation of the GoF Composite pattern.

There are some Delphi 2007 code templates in the install and SVN that simplify the development of your business object model.
We require a TPerson class, as well as a TPersonList container as shown in the UML below:



In Person_BOM.pas, add the following class definitions:

uses
tiObject;

type

  TPerson = class;
  TPersonList = class;

  TPersonList = class(TtiObjectList)
  protected
    function    GetItems(i: integer): TPerson; reintroduce;
    procedure   SetItems(i: integer; const AValue: TPerson); reintroduce;
  public
    property    Items[i:integer]: TPerson read GetItems write SetItems;
    procedure   Add(const AObject : TPerson); reintroduce;
  end;

  TPerson = class(TtiObject)
  protected
    function    GetParent: TPersonList; reintroduce;
  public
    property    Parent: TPersonList read GetParent;
end;
In TPersonList, the Items[] property and access methods are recast to accept and return TPerson instances. This requires some effort upfront that will be rewarded when you start using the TPersonList in code.

In TPerson, the Parent property is recast to return TPersonList. This makes it possible to walk up an object tree with the IDE and compiler helping you as much as possible along the way.

Compile and run the unit tests.

TPerson requires some properties so add the following to TPerson’s published section. (The properties must be published as we are using tiOPF’s Auto-map functionality for object persistence. tiOPF supports alternative mapping strategies that do not require properties to be published.

Side bar: Published properties or persistent fields?

The tiOPF allows you to model object data using published properties, or tiOPF’s implementation of persistent fields (descending from TtiField)

Using published properties is suitable for most applications, is simpler to code and leads to lower demands on memory at runtime.

Persistent fields are required if null support is necessary. Persistent fields also make it possible to generate light weight SQL update statements by checking each field’s IsDirty property before generating the SQL. Persistent fields also have the advantage that object metadata can be modelled in a central location and used for data validation. (eg Not Null, Maximum field width, Check constraints)

Add the following properties to TPerson:

  TPerson = class(TtiObject)
  …
  published
    property Title: string read FTitle write FTitle;
    property FirstName: string read FFirstName write FFirstName;
    property LastName: string read FLastName write FLastName;
    property Initials: string read FInitials write FInitials;
  end;

TtiObject and TtiObjectList have a virtual method Read() which must be overridden to provide access to the persistence mechanism. We do not want to change the behaviour of Read() but simply raise its visibility from protected to public so Read() can be called in our code.

Add the following to TPersonList:
  TPersonList = class(TtiObjectList)

public
procedure Read; override;
end;

implementation

procedure TPersonList.Read;
begin
inherited;
end;
Rough out the TPersonTestCase with the following code:

implementation
uses
Person_BOM,
TestFramework,
tiDialogs;

procedure TPersonTestCase.PersonList_Read;
var
LList: TPersonList;
begin
LList := TPersonList.Create;
try
LList.Read;
// tiShowString lets us confirm persistence is working, before
// writing the details of the test
tiShowString(LList.AsDebugString);
finally
LList.Free;
end;
end;
Run the unit tests and you will see the dialog shown below:



This tells us we are looking at an empty TPersonList, with an ObjectState of posClean, and an empty OID.

Side bar: About ObjectState

The tiOPF supports the concept of ObjectState.

An object can be in one of the following states:

posEmpty    The object has been created, but not filled with data from the DB
posPK    The object has been created, it’s OID and human readable primary key info has been read. posCreate    The object has been created and populated with data and must be saved to the DB
posUpdate    The object has been changed, the DB must be updated
posDelete    The object has been deleted, it must be deleted from the DB
posDeleted    The object was marked for deletion, and has been deleted in the database
posClean    The object is 'Clean' no DB update necessary

Persisting TPerson

To persist TPerson we must register the following relationships:
The mappings between class & table, properties and fields are registered with the following call to ClassDBMappingMgr:

GTIOPFManager.ClassDBMappingMgr.RegisterMapping(
<ClassName>,
<TableName>,
<PropertyName>,
<Database field name>,
[<Optional relationship info>]);
GTIOPFManager is the globally available, single instance of TtiOPFManager, which maintains a list of registered persistence layers, databases and mapping relationships.

ClassDBMappingMgr (which stands for "class database mapping manager") is a property of TtiOPFManager and it’s design is based on a paper by Scott Ambler.

The first four parameters are self evident. The fifth parameter <Optional relationship info> is used to specify if a property / field is a database primary or foreign key.

Object to container relationships are registered with the following call:

GTIOPFManager.ClassDBMappingMgr.RegisterCollection(
<List - TtiObjectList descendant>,
<Item – TtiObject descendant>);
To see this in action, add the following code to Person.pas’s Initialization section:

implementation
uses
tiOPFManager,
tiAutoMap;



initialization
GTIOPFManager.ClassDBMappingMgr.RegisterMapping(TPerson, 'person', 'oid', 'oid', [pktDB]);
GTIOPFManager.ClassDBMappingMgr.RegisterMapping(TPerson, 'person', 'FirstName','first_name');
GTIOPFManager.ClassDBMappingMgr.RegisterMapping(TPerson, 'person', 'LastName', 'last_name');
GTIOPFManager.ClassDBMappingMgr.RegisterMapping(TPerson, 'person', 'Initials', 'initials');
GTIOPFManager.ClassDBMappingMgr.RegisterMapping(TPerson, 'person', 'Title', 'title');
GTIOPFManager.ClassDBMappingMgr.RegisterCollection(TPersonList, TPerson);
Run the unit tests again and the test list will still be empty because we have not put any records into the database.

Side bar: Other mapping strategies in the tiOPF

The tiOPF supports three object – database mapping strategies. These are “Automapping” (used in this demo), “DB Independent visitors” and “Hard coded visitors”

The following matrix will help you decide which is the best for your application.

Feature
Automapping
DB independent
Hard coded
Flat files
Yes
Yes
No
Use stored procedures
No
No
Yes
Swap database easilly
Yes
Yes
Perhaps
Control over SQL
No
No
Complete
Control over performance
No
No
Complete
Learning curve
Easy
Moderate
Hard

Testing TPerson.Read

We can insert a record by running some SQL as part of the unit test. This can be done via a call to GTIOPFManager.ExecSQL().

Add the following code to the unit test:

procedure TPersonTestCase.PersonList_Read;
var
LList: TPersonList;
begin
GTIOPFManager.ExecSQL(
'insert into person ' +
'(OID, FIRST_NAME, LAST_NAME, TITLE) ' +
'values ' +
'(''1000'', ''Edna'', ''Everage'', ''Dame'') ');

LList := TPersonList.Create;
try
LList.Read;
tiShowString(LList.AsDebugString)
finally
LList.Free;
end;
end;

Run the unit tests again and you will see a single object has been added to the list:



All that remains is to add some calls to CheckEquals() to confirm the object’s data has been read correctly..

Add the following code to the unit test:
  LList := TPersonList.Create;
try
LList.Read;
CheckEquals(1, LList.Count);
CheckEquals('1000', LList.Items[0].OID.AsString);
CheckEquals('Edna', LList.Items[0].FirstName);
CheckEquals('Everage', LList.Items[0].LastName);
CheckEquals('Dame', LList.Items[0].Title);
finally
LList.Free;
end;

This checks there is exactly one object returned from the database, and confirms it’s properties have been set as expected.

But, there is a problem running the test multiple times as the SQL Insert will fail with a unique key violation. The solution is to empty all tables in the database before the test is run. This is done with a call to GTIOPFManager.DeleteRow() as shown below:
procedure TPersonTestCase.PersonList_Read;
var
LList: TPersonList;
begin
GTIOPFManager.DeleteRow('person', nil);

GTIOPFManager.ExecSQL(
'insert into person ' +
'(OID, FIRST_NAME, LAST_NAME, TITLE) ' +
'values ' +
'(''1000'', ''Edna'', ''Everage'', ''Dame'') ');

DeleteRow takes two parameters:

GTIOPFManager.DeleteRow(<Table Name>, <Query Parameters>);

The meaning of table name is self evident. <Query Parameters>, if assigned will be a TtiQueryParams objects that contains a list of ‘Field Name’ – ‘Value pairs’ what are used to build a SQL WHERE clause.

We have unit tested basic TPersonList.Read() functionality, but the code we have written will be hard to maintain as the application grows because:

Testing TPerson.Save (Create a new instance)

To test TPerson.Save() we create a new TPerson, populate it’s properties then call Save(). We then call TPersonList.Read and check the TPerson has been read correctly. This tests assumes that TPerson.Read() is working correctly, which is why we wrote the test for TPerson.Read() first.

Rough out the unit tests shown below using copy & paste to clone the code that was written to test TPerson.Read() (Don’t worry, we will refactor this cloned code later.)

procedure TPersonTestCase.PersonList_Create;
var
LList: TPersonList;
LItem: TPerson;
begin
GTIOPFManager.DeleteRow('person', nil);

LItem:= TPerson.Create;
try
LItem.OID.AsString:= '1000';
LItem.FirstName:= 'Edna';
LItem.LastName:= 'Everage';
LItem.Title:= 'Dame';
LItem.Dirty:= True; // <<== Tell the OPF this object is to be saved
LItem.Save;
finally
LItem.Free;
end;

LList := TPersonList.Create;
try
LList.Read;
CheckEquals(1, LList.Count);
CheckEquals('1000', LList.Items[0].OID.AsString);
CheckEquals('Edna', LList.Items[0].FirstName);
CheckEquals('Everage', LList.Items[0].LastName);
CheckEquals('Dame', LList.Items[0].Title);
finally
LList.Free;
end;

end;

Compile and run the unit tests.

Looking at PersonList_Read and PersonList_Create, the calls to DeleteRow() and CheckEquals() are candidates for abstraction. The code that creates and populates an instance of TPerson will be used when testing update and delete so we shall abstract that as well.

Abstracting object & database setup code

For each class, there are actually four method we require to simplify unit testing. For the TPerson class we shall call these:

PersonAssign:    Assigns seed data to a person, populating all fields;
PersonCreate:    Creates an instance of TPerson, then calls PersonAssign();
PersonInsert:      Calls PersonCreate, then inserts into the database;
PersonCheck:    Calls PersonCreate to create a reference then checks against a test instance.

Create the TPersonSetup class

A large application will probably have hundreds of classes that are persisted. With each class needing at least four setup methods the test code can quickly become unwieldy so we group the setup methods into related families. The abstract class TtiTestSetup is used as a starting point.

Add the following code to Person_TST.pas:
interface
uses
tiTestFramework,
tiTestSetup,
Person_BOM;

type

TPersonSetup = class(TtiTestSetup)
public
procedure PersonAssign(const APerson: TPerson; const AOID: string);
function PersonCreate(const AOID: string): TPerson;
procedure PersonInsert(const AOID: string);
procedure PersonCheck(const APerson: TPerson; const AOID: string);
end;

TPersonTestCase = class(TtiTestCase)
private
FPersonSetup: TPersonSetup;
protected
procedure SetupOnce; override; // <<== SetUpOnce, new to DUnit2 will
procedure TearDownOnce; override; //<<== be called once for each test run.



procedure TPersonTestCase.SetupOnce;
begin
inherited;
FPersonSetup:= TPersonSetup.Create(Self);
end;

procedure TPersonTestCase.TearDownOnce;
begin
FPersonSetup.Free;
inherited;
end;
The TPersonTestCase owns an instance of TPersonSetup. In a larger application, TPersonSetup may be owned by an abstract TMyAppTestCase, or it may be added to each TestCase as required.

Implement PersonAssign()

PersonAssign takes an empty instance of TPerson and sets all its properties to unique values based on AOID. This is done as follows:

procedure TPersonSetup.PersonAssign(const APerson: TPerson; const AOID: string);
begin
APerson.FirstName:= tvToStr(AOID, 1);
APerson.LastName:= tvToStr(AOID, 2);
APerson.Title:= tvToStr(AOID, 3);
APerson.Initials:= tvToStr(AOID, 4);
end;

tvToStr() will ‘Increment’ AOID so each property of TPerson is assigned a unique value. These properties will be meaningless strings based on the value of AOID, but they will be unique which will test our persistence code does not cross wire any mappings between fields and the database.

PersonAssign() does not set the TPerson’s OID as PersonAssign() will be used to test updates. For this test, OID must remain unchanged.

Implement PersonCreate()

PersonCreate() takes a seed value and returns a populated instance of TPerson as shown in the code below:

function TPersonSetup.PersonCreate(const AOID: string): TPerson;
begin
result:= TPerson.Create;
result.OID:= AOID;
PersonAssign(result, AOID);
end;
Note that the OID is set in PersonCreate()

Implement PersonInsert()

PersonInsert() takes a seed value, creates a TPerson, saves it to the database using a tiOPF technique that works for both SQL and non SQL database as shown in the code below:
procedure TPersonSetup.PersonInsert(const AOID: string);
var
LPerson: TPerson;
LParams: TtiQueryParams;
begin
LPerson:= nil;
LParams:= nil;
try
LPerson:= PersonCreate(AOID);
LParams:= TtiQueryParams.Create;
LParams.SetValueAsString('oid', LPerson.OID.AsString);
LParams.SetValueAsString('first_name', LPerson.FirstName);
LParams.SetValueAsString('last_name', LPerson.LastName);
LParams.SetValueAsString('title', LPerson.Title);
LParams.SetValueAsString('initials', LPerson.Initials);
GTIOPFManager.InsertRow('person', LParams);
finally
LPerson.Free;
LParams.Free;
end;
end;
PersonCreate() is used to seed a fresh instance of TPerson.

TtiQueryParams is populated with field name – value pairs and is passed to GTIOPFManager.InsertRow(), along with the table name to insert the record. This technique will work with both SQL and non SQL databases.

Sidebar: TtiCriteria

TtiQueryParams supports the construction of simple insert, update and delete statements where each parameter equals a single value. For queries requiring more complex operators like AND, OR, NOT EQUALS, GREATER THAN, the TtiCriteria classes should be used.

PersonCheck() takes an instance of TPerson and a seed value. A reference instance of TPerson is created and is used in calls to CheckEquals(). This is shown in the code below:

procedure TPersonSetup.PersonCheck(const APerson: TPerson; const AOID: string);
var
LPerson: TPerson;
begin
LPerson:= PersonCreate(AOID);
try
TC.CheckEquals(LPerson.FirstName, APerson.FirstName);
TC.CheckEquals(LPerson.LastName, APerson.LastName);
TC.CheckEquals(LPerson.Title, APerson.Title);
TC.CheckEquals(LPerson.Initials, APerson.Initials);
finally
LPerson.Free;
end;
end;
The CheckEquals() methods are being called from within a TtiTestCaseSetup descendant, so must be made by the referenced TC (TestCase)

OID is not checked as there will be cases when OID is deliberately out of sync with the seed value. OIDs are checked in the individual test methods.

Refactoring the Person_Read and Person_Create tests

We can now tidy up the tests for Person_Read and Person_Create.

The code that trashes the database can be moved to the TestCase’s SetUp method. While we are about it, we shall extend this code to empty all tables as shown below:

procedure TPersonTestCase.SetUp;
begin
inherited;
GTIOPFManager.DeleteRow('person', nil);
GTIOPFManager.DeleteRow('adrs', nil);
GTIOPFManager.DeleteRow('eadrs', nil);
GTIOPFManager.DeleteRow('adrs_type', nil);
GTIOPFManager.DeleteRow('eadrs_type', nil);
end;
Person_Read can be modified to reference the PersonSetup methods as shown in the code below:

procedure TPersonTestCase.PersonList_Read;
var
LList: TPersonList;
begin
PersonSetup.PersonInsert(COIDPerson1);
LList := TPersonList.Create;
try
LList.Read;
CheckEquals(1, LList.Count);
PersonSetup.PersonCheck(LList.Items[0], COIDPerson1);
CheckEquals(COIDPerson1, LList.Items[0]);
finally
LList.Free;
end;
end;
And now Person_Create can be tidied up:

procedure TPersonTestCase.PersonList_Create;
var
LList: TPersonList;
LItem: TPerson;
begin
LItem:= PersonSetup.PersonCreate(COIDPerson1);
try
LItem.Dirty:= True;
LItem.Save;
finally
LItem.Free;
end;

LList := TPersonList.Create;
try
LList.Read;
PersonSetup.PersonCheck(LList.Items[0], COIDPerson1);
CheckEquals(COIDPerson1, LList.Items[0]);
finally
LList.Free;
end;
end;

Testing TPerson.Save (Update an existing instance)

To test updating a TPerson, we must:
This is shown in the code below:
procedure TPersonTestCase.Person_Update;
var
LList: TPersonList;
begin
PersonSetup.PersonInsert(COIDPerson1); // <<== Insert Person #1
LList := TPersonList.Create;
try
LList.Read;
PersonSetup.PersonCheck(LList.Items[0], COIDPerson1);// <<== Check it's Person #1
PersonSetup.PersonAssign(LList.Items[0], COIDPerson2);// <<== Assign to Person #2
LList.Items[0].Dirty:= True;
LList.Items[0].Save;
finally
LList.Free;
end;

LList := TPersonList.Create;
try
LList.Read;
PersonSetup.PersonCheck(LList.Items[0], COIDPerson2); // <<== Check it's Person #2
finally
LList.Free;
end;
end;

Testing TPerson.Save (Delete an existing instance)

To test deleting a TPerson, we must:
This is show in the code below:
procedure TPersonTestCase.Person_Delete;
var
LList: TPersonList;
begin
PersonSetup.PersonInsert(COIDPerson1);
LList := TPersonList.Create;
try
LList.Read;
LList.Items[0].Deleted:= True; // <<== This object is to be deleted
LList.Items[0].Save;
finally
LList.Free;
end;

LList := TPersonList.Create;
try
LList.Read;
CheckEquals(0, LList.Count); //<<== Should have been deleted
finally
LList.Free;
end;
end;
Sidebar: About OIDs

Each persisted object is uniquely identified by an OID (Object ID)

You can also use tiOPF against ‘legacy’ database that do not implement the concept of OID.

By default, tiOPF uses GUIDS for OID, but a number of other strategies including Scott Amblers High / Low integers, Hex and database sequence generators are available.

The OID strategy is set by assigning GTIOPFManager.OIDGenerator.

Alternative OID generators can be found in the Options directory.

Adding a VCL Forms (modal) user interface

If you have read About Face, then you will be familiar with the potential evils of modal user interfaces. They are, however very easy to code so we shall start with an interface based on popup forms.

Rough out the application

Create a new VCL Forms application and save it to AdrsBook\GUI under the name AdrsBookGUIModalForms.

Save its main form as FMain.pas

In Project | Options, set

output directory to     ..\_bin;
Unit output directory to _dcu;
Search Path to         ..\BOM;

In FMain.pas’s uses clause, add tiQueryIBX to force linking of the IBX persistence layer.

In FormMain’s FormCreate event, add a call to GTIOPFManager.ConnectDatabase()

Create an instance of TPersonList owned by TFormMain and call its Read method in FormMain’s FormCreate event.

When this is done, FMain.pas will look like this:

unit FMain;
interface
uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, Person_BOM;
type
  TFormMain = class(TForm)
    procedure FormCreate(Sender: TObject);
   procedure FormDestroy(Sender: TObject);
  private
    FPersonList: TPersonList;
  public
    { Public declarations }
  end;
var
  FormMain: TFormMain;
implementation
uses
  tiOPFManager,
  tiQueryIBX;
{$R *.dfm}
procedure TFormMain.FormCreate(Sender: TObject);
begin
  GTIOPFManager.ConnectDatabase('adrs', 'adrs.fdb', 'SYSDBA', 'masterkey', '', '');
  FPersonList:= TPersonList.Create;
  FPersonList.Read;
end;
procedure TFormMain.FormDestroy(Sender: TObject);
begin
  FPersonList.Free;
end;

The main form – a TVirtualTreeView of TPeople

We shall use the TtiVTListView to display a list of TPeople. The TtiVTListView is a wrapper around the TVirtualTree that makes it easy to use with the tiOPF.

Add a TtiVTListView to FormMain and set its align property to alClient. In FormMain’s FormCreate event, add the following code to setup the relationship between the list view columns and TPerson’s properties:

Set the TtiVTListView’s data property to FPersonList as shown in the code below:

procedure TFormMain.FormCreate(Sender: TObject);
begin
  GTIOPFManager.ConnectDatabase('adrs', 'adrs.fdb', 'SYSDBA', 'masterkey', '', '');
  FPersonList:= TPersonList.Create;
  FPersonList.Read;
  LV.AddColumn('Title', vttkString, 'Title', 100);
  LV.AddColumn('FirstName', vttkString, 'First Name', 200);
  LV.AddColumn('LastName', vttkString, 'Last Name', 200);
  LV.Data:= FPersonList;
end;

Set the TtiVTListView’s VisibleButtons property to [tiLVBtnVisEdit,tiLVBtnVisNew,tiLVBtnVisDelete] These buttons provide a quick path to coding a popup modal form:



Double click on the ListView’s OnItemInsert, OnItemEdit and OnItemDelete events to create a place holder for some code.

In OnItemInsert, add the following test code:

procedure TFormMain.LVItemInsert(
pVT: TtiCustomVirtualTree; AData: TtiObject; AItem: PVirtualNode);
var
  LData: TPerson;
begin
  LData:= TPerson.CreateNew;
  FPersonList.Add(LData);
  LData.LastName:= 'test';
  LData.FirstName:= 'test';
  LData.Save;
  LV.Refresh(LData);
end;
In OnItemDelete, add the following code:
procedure TFormMain.LVItemDelete(
pVT: TtiCustomVirtualTree; AData: TtiObject; AItem: PVirtualNode);\
begin
  if tiObjectConfirmDelete(AData) then // Add tiGUIUtils to the uses
  begin
    AData.Deleted:= True;
    (AData as TPerson).Save;
    FPersonList.FreeDeleted;
    LV.Refresh;
  end;
end;
Compile and run the application and test Insert and Delete work.

The next step is to write a form for editing a TPerson.

The TPerson Edit form

We shall use TFormTIPerEditDialog as the parent for our edit form. TFormTIPerEditDialog can be found in \tiOPF2\GUI\FtiPerEditDialog.pas so add it to the project’s DPR.

Create a form called FormPersonEdit that descends from TFormTIPerEditDialog and save it to FPersonEdit.pas

Add four TtiPerAwareEdit controls to the form, an TLabel and arrange them as shown (Set the TLabel’s AutoSize property to false, it’s Transparent property to false and it’s colour property to something ghastly for displaying error messages.



Override the form’s SetData() and IsValid() methods. The implementation of SetData() is shown below:

procedure TFormPersonEdit.SetData(const AValue: TtiObject);
begin
  inherited;
  paeTitle.LinkToData(DataBuffer, 'Title');
  paeFirstName.LinkToData(DataBuffer, 'FirstName');
  paeLastName.LinkToData(DataBuffer, 'LastName');
end;
Inherited is called so TFormTIPerEditDialog .SetData() can clone the instance being edited. The cloned copy is edited and compared against the copy that has been passed. This is used to provide basic undo functionality, as well as enable / disable the OK button. The data instance that was passed to the form’s Execute method can be accessed via the Data property. The cloned instance can be accessed via the DataBuffer property.

The implementation of FormIsValid() is shown below:
function TFormPersonEdit.FormIsValid: boolean;
var
  LS: string;
begin
  result:= DataBuffer.IsValid(LS);
  lblErrors.Caption:= LS;
  lblErrors.Visible:= LS <> '';
end;
FormIsValid depends on some functionality in TPerson.IsValid that we have not yet implemented.

Implement TPerson’s IsValid, GetCaption. Test Assign & Equals

To finish up, we need to implement and unit test four remaining methods on TPerson:

TPerson.IsValid

IsValid is where TPerson validation code resides. Adding this code to the business object rather than the form makes unit testing simple. This also makes it possible to edit TPerson objects from a number of locations as the validation logic is centralised.

The code for TPerson.IsValid is shown below:

function TPerson.IsValid(const AErrors: TtiObjectErrors): boolean;
begin
  result:= inherited IsValid(AErrors);
  if (FirstName = '') and (LastName = '') then
    AErrors.AddError(CErrorPersonNameNotAssigned);
  // ToDo: Add code to check field lengths will fit in the DB
  result:= AErrors.Count = 0;
end;
With IsValid implemented, the edit form will show an error message, and disable the OK button as shown below:


Unit testing IsValid on complex objects can be tricky as all combinations of inputs must be tested. The code for testing TPerson.IsValid is shown below:

procedure TPersonTestCase.Person_IsValid;
var
  LItem: TPerson;
  LErrors: TtiObjectErrors;
begin
  LErrors:= nil;
  LItem:= nil;
  try
    LErrors:= TtiObjectErrors.Create;
    LItem:= PersonSetup.PersonCreate(cOIDPerson1);
    Check(LItem.IsValid(LErrors));
    CheckEquals(0, LErrors.Count);
    LItem.Title:= '';
    Check(LItem.IsValid(LErrors));
    CheckEquals(0, LErrors.Count);
    LItem.Initials:= '';
    Check(LItem.IsValid(LErrors));
    CheckEquals(0, LErrors.Count);
    LItem.FirstName:= '';
    Check(LItem.IsValid(LErrors));
    CheckEquals(0, LErrors.Count);
    LItem.LastName:= '';
    Check(not LItem.IsValid(LErrors));
    CheckEquals(1, LErrors.Count);
    CheckEquals(CErrorPersonNameNotAssigned, LErrors.Items[0].ErrorMessage);
  finally
    LErrors.Free;
    LItem.Free;
  end;
end;

TPerson.GetCaption

When you delete a TPerson, you are prompted with the following confirmation dialog:



To create a more friendly message, you need to implement TPerson.GetCaption as shown below:

function TPerson.GetCaption: string;
begin
  result:= Title;
  if FirstName <> '' then
    result:= result + ' ' + FirstName;
  if LastName <> '' then
    result:= result + ' ' + LastName;
end;
GetCaption must also be unit tested (which is trivial so not shown here).

TPerson.Equals

The edit dialog uses TtiObject.Equals() to compare two objects and if they are found to be different, the OK button is enabled. Unit testing Equals can be complex with large object hierarchies. The basics of testing Equals() is shown below:
procedure TPersonTestCase.Person_Equals;
var
  LItem1: TPerson;
  LItem2: TPerson;
begin
  LItem1:= nil;
  LItem2:= nil;
  try
    LItem1:= PersonSetup.PersonCreate(COIDPerson1);
    LItem2:= PersonSetup.PersonCreate(COIDPerson1);
    TestTIObjectEquals(LItem1, LItem2, 'LastName');
    TestTIObjectEquals(LItem1, LItem2, 'FirstName');
    TestTIObjectEquals(LItem1, LItem2, 'Title');
    TestTIObjectEquals(LItem1, LItem2, 'Initials');
  finally
    LItem1.Free;
    LItem2.Free;
  end;
end;
TestTIObjectEquals is implemented in TtiTestCase and is used to test Equals by changing one property at the time as shown in the following code fragment:

procedure TtiTestCase.TestTIObjectEquals(
  const AObj1, AObj2: TtiObject; const APropName: String);
var
  LSavedStr: string;
begin
  Check(AObj1.Equals(AObj2), 'Expected equality');
  LSavedStr:= AObj2.PropValue[APropName];
  AObj2.PropValue[APropName]:= AObj2.PropValue[APropName] + 'A';
  Check(not AObj1.Equals(AObj2), 'Expected inequality');
  AObj2.PropValue[APropName]:=LSavedStr;
  Check(AObj1.Equals(AObj2), 'Expected equality');
end;

TPerson.Assign

The edit dialog uses TtiObject.Assign() to make a copy of the object before editing. Assign is called again when the object is to be saved. Testing of Assign() assumes Equals() has been thoroughly tested and is illustrated in the code below:
procedure TTestPerson.Person_Assign;
var
  LFrom: TPerson;
  LTo  : TPerson;
begin
  LFrom:= PersonSetup.PersonCreate(COIDPerson1);
  try
    LTo:= TPerson.Create;
    try
      LTo.Assign(LFrom);
      PersonSetup.PersonCheck(LTo, COIDPerson1);
    finally
      LTo.Free;
    end;
  finally
    LFrom.Free;
  end;
end;

Conclusion

This paper is part one of four and has covered the basics of using tiOPF to persist and unit test a simple collection of objects.

Part II will cover persisting and unit testing owned objects (the TPerson’s addresses and e-addresses) as well as association objects (TAddress is associated to a TAddressType) (This paper has not been written yet.)

Part III will explain how to modify the application to persist to XML or over the internet via the tiOPF application server using port #80, HTTP & XML. (This paper has not been written yet.)

Part IV will conclude by showing how the unit test, build, install and deployment process is automated with FinalBuilder & InnoSetup. (This paper has not been written yet.)

You are welcome to discuss this paper on the tiOPF news group here.