Delphi - Kylix Database Development
Delphi - Kylix Database Development
Development
Eric Harmon
COVER DESIGNER
Gary Adair
PAGE LAYOUT
Ayanna Lacey
Contents at a Glance
Introduction 1
1 Establishing and Using Database Connections 7
2 dbExpress Datasets 57
3 Client Dataset Basics 93
4 Advanced Client Dataset Operations 147
5 Data-Aware Components 201
6 Data-Aware Grids 239
7 Dataset Providers 273
8 DataSnap 317
9 The ConMan Application 347
Appendixes
A Redistributing dbExpress Applications 375
B dbExpress Plus 379
Index 385
Contents
Introduction 1
Who This Book Is For ............................................................................1
How This Book Is Organized ..................................................................1
VCL or CLX? ....................................................................................2
Components Developed in This Book................................................3
Sample Applications ..........................................................................3
dbExpress............................................................................................5
Databases Used in This Book ............................................................6
Conventions Used in This Book ..............................................................6
Contacting the Author..............................................................................6
8 DataSnap 317
What Is DataSnap? ..............................................................................318
Creating the Application Server ..........................................................318
Remote Data Modules ....................................................................318
Creating the Application Server’s User Interface ..........................326
Preparing the Application Server for Testing ................................328
Creating the Client Application ..........................................................329
Connecting to a Local Database Connection ................................329
Connecting to a Remote Database Connection..............................330
A Complete Example ..........................................................................336
The Briefcase Model............................................................................340
Stateless Servers ..................................................................................341
Sharing a Connection Between Multiple Client DataSets ..................343
Brokering Connections Between Multiple Servers ............................344
Summary ..............................................................................................345
ix
CONTENTS
Appendixes
A Redistributing dbExpress Applications 375
Redistributable Files ............................................................................376
Redistributing a Windows Application ..........................................376
Redistributing a Linux Application ................................................377
Licensing Issues ..................................................................................378
CD-ROM-Based Applications ............................................................378
Index 385
About the Author
Eric Harmon is Director of Software Development at Advanced Estimating Systems, Inc.,
located in Delray Beach, Florida. Advanced Estimating Systems is the developer of The
EDGE, the industry standard in construction-estimating software. Eric is also a member of
TPX (TurboPower experts), a volunteer group of programmers that assists the TurboPower
Software company in providing support for its newsgroups. TurboPower is one of the premier
providers of tools coded in Delphi for Delphi programmers. Eric was recruited by TurboPower
as the original member of TPX in 1997. He has contributed Delphi- and COM-related
articles to Visual Developer Magazine and is the author of the highly regarded book
Delphi COM Programming (MTP/New Riders, 2000). Eric can be reached at
[email protected].
Dan Miser is a Research and Development Project Manager for the DSP group at Borland,
where he spends most of his time researching emerging technologies. Dan also worked on the
Delphi R&D team where his responsibilities included DataSnap development. Dan’s major
focus is finding ways to allow information to be shared across boundaries, and this has
allowed him to work with a variety of distributed computing technologies, including MIDAS,
SOAP, DCOM, RMI, J2EE, EJB, Struts, and RDS. He has also been involved with promoting
Delphi by contributing to the “Delphi x Developer’s Guide,” acting as a technical editor, writ-
ing magazine articles, participating on the Borland newsgroups as a member of TeamB, and
speaking at BorCon on topics such as COM and MIDAS.
Ramesh Theivendran has been a member of the SQL Links research and development team
since October 1995. Prior to joining Borland, Ramesh was employed as a Programmer at the
Indian Institute of Technology, Bombay (IITB) and as a Systems Analyst in Ramco Systems,
Madras, INDIA. He has over 10 years of experience in client/server tools development.
Currently, he leads the database connectivity efforts at Borland in its RAD products group and
serves as an architect for dbExpress. Ramesh lives in Santa Cruz, California with his wife,
Aruna, and their little one, Vineha.
Philippe Bruno is the Director of Research and Development at Scanpak Inc., a firm head-
quartered in Montreal, Quebec, specializing in radio frequency identification (RFID) systems.
Scanpak is the creator of GETS (Galley Equipment Tracking System), an asset tracking sys-
tem specifically targeted to the airline industry. He is also a part-time teacher for computer-
related courses in various universities and colleges in the Montreal area. Philippe has
programmed in several computer languages since 1987, but Pascal and Delphi have always
been his favorites. He is also a member of TPX (TurboPower experts), where he volunteers his
expertise in serial communications, networks, and protocols to the service of fellow program-
mers in the TurboPower newsgroups.
Dedication
For my wife, Tina.
Acknowledgments
Writing a book isn’t a one-man (or woman) operation, and I would like to thank the people
who helped take this book from the concept stage to reality.
Once again, Karen Wachs at Sams worked with me on this book from beginning to end. She
patiently led me through the process of writing my first book and was back to assist on this
one, also. She’s a pleasure to work with. Thanks, Karen! I also want to thank Katie Robinson
and Chip Gardner, who copyedited the text and fixed up my typos and grammatical errors.
Thanks to Heather McNeill, who oversaw this book through all its stages of production and
helped to make sure that things ran smoothly; and to Laurie McGuire, who suggested ways to
improve the flow of the text and otherwise ensured that the overall quality of the book was up
to par.
I’d like to say a special thanks to my technical reviewers, Dan Miser and Ramesh Theivendran,
both Borland employees, who provided large quantities of extremely helpful feedback and
pointed out where I made technical mistakes. Ramesh is one of the key dbExpress engineers,
and Dan is well known for his MIDAS expertise. In addition, Phillipe Bruno provided valuable
and timely technical review of the final chapter and appendixes. I couldn’t have asked for bet-
ter tech reviewers.
With all these people assisting me, I have made every attempt to fix all errors, both technical
and typographical, that may have originally appeared in the manuscript. Writing a book is a
very complex process, and inevitably, some errors will have survived. Any errors that remain
are, of course, my own fault.
My apologies to anyone who I may have inadvertently omitted. A number of people worked on
this book that I never had direct contact with, so I don’t know them individually. Thanks to all
those whose names I didn’t specifically mention.
Tell Us What You Think!
As the reader of this book, you are our most important critic and commentator. We value your
opinion and want to know what we’re doing right, what we could do better, what areas you’d
like to see us publish in, and any other words of wisdom you’re willing to pass our way.
As an associate publisher for Sams, I welcome your comments. You can e-mail or write
me directly to let me know what you did or didn’t like about this book—as well as what we
can do to make our books stronger.
Please note that I cannot help you with technical problems related to the topic of this book,
and that because of the high volume of mail I receive, I might not be able to reply to every
message.
When you write, please be sure to include this book’s title and author as well as your name
and phone or fax number. I will carefully review your comments and share them with the
author and editors who worked on the book.
E-mail: [email protected]
VCL or CLX?
Because the technology discussed in this book applies equally well to Delphi 6 and Kylix
(with the exception of Chapter 8, “DataSnap”), all the code listings in this book are CLX list-
ings. The downloadable source code is provided in both CLX and VCL form, so if you don’t
write cross-platform applications, you may want to experiment with the VCL code instead.
In case you aren’t familiar with these terms, VCL stands for Visual Component Library; it is
the original, Windows-specific class library supported by Delphi. CLX stands for Component
Library Cross-Platform (the X stands for Cross-Platform) and is the new, cross-platform class
library supported both by Delphi and Kylix.
CLX is broken down into four categories:
• BaseCLX This includes the “behind-the-scenes” utility classes and functions, such as
TStringList, TObjectList, and so on.
• DataCLX This wraps the CLX database functionality, such as dbExpress and data-
aware components.
• VisualCLX This includes visual components such as menu bars, toolbars, buttons, list
boxes, and so on.
• NetCLX This includes Internet-related components.
The only part of CLX specifically discussed in this book is DataCLX (although bits and pieces
of BaseCLX and VisualCLX are used to create the sample CLX applications).
You’ll see from the code listings that apart from the uses clause at the top of each unit, there is
almost no difference between the VCL code and CLX code, so you shouldn’t have any trouble
following along with the CLX code.
3
INTRODUCTION
• TETHDBGrid A descendent of TDBGrid that fires an event when the user resizes a column.
Sample Applications
Each chapter in this book includes a number of sample applications to help you understand the
concepts being discussed. The samples were all compiled and tested using Delphi 6—both the
VCL and CLX versions.
The source code for the sample programs can be downloaded from http://
www.samspublishing.com/detail_sams.cfm?item=067232265x6 or from my own Web site,
located at http://www.tpx.turbopower.com/~Eric.Harmon. In the latter case, click the Books
and Articles link, and then click the download link near the top of the page.
The following list provides a road map, by chapter, of the sample applications developed in
this book.
Chapter 1
• Events Illustrates the different connection events fired by the TSQLConnection component.
• MetaData Shows how to retrieve simple metadata information from a dbExpress
connection.
• DDLSQL Shows how to send DDL and SQL commands directly to a TSQLConnection
component.
• Trans Illustrates how transaction support works in dbExpress.
• Feedback Shows how to provide feedback about what’s happening in a dbExpress
connection.
4
DELPHI/KYLIX DATABASE DEVELOPMENT
Chapter 2
• Basic Illustrates basic TSQLDataSet operation.
• Advanced Shows more advanced TSQLDataSet methods and operations.
• Schema Shows how to retrieve more advanced metadata information from a dbExpress
connection using TSQLDataSet.
Chapter 3
• CDS Shows the basics of client dataset support.
• Navigate Shows how to navigate through a TClientDataSet.
• CDSIndex Illustrates how to create and use indexes on a TClientDataSet.
• RangeFilter Shows how to limit the amount of data in a TClientDataSet by applying
ranges and filters.
• Search Shows a variety of ways to quickly locate a given record in a client dataset.
Chapter 4
• EventLog Illustrates the events fired by TClientDataSet.
• Updates Shows how to disable and enable updates to data-aware controls to speed
dataset operations.
• BLOBs Shows how to store pictures and notes in a client dataset.
• Nested Shows how client datasets implement master/detail relationships.
• ChangeLog Shows how to implement undo support using a client dataset.
• Clone Illustrates cloning, which is a way to create a duplicate copy of a
TClientDataSet.
Chapter 5
• DataAware Illustrates a variety of data-aware components discussed in the chapter.
Chapter 6
• Options Shows how the various options for a TDBGrid work.
• CustomDraw Illustrates the correct way to override the TDBGrid’s default drawing to
provide visually exciting grids.
• CtrlGrid VCL-only sample that shows how to use the TDBCtrlGrid component.
Chapter 7
• Updates Shows the basic operation of dataset providers.
• Joins Shows how to correctly resolve data that was retrieved through an SQL JOIN.
• DataFetch Illustrates how to limit the amount of BLOB and detail data returned from a
dataset to speed application performance.
5
INTRODUCTION
Chapter 8
• Methods Shows how to add callable methods to an application server.
• LocalConn Shows how to implement a single-EXE database application using multitier
techniques.
• Stateless Shows how to create a stateless application server for use with MTS or
COM+.
Chapter 9
• ConMan A complete sample application that draws on many of the techniques discussed
throughout the book to create a simple contact manager.
With respect to the source code, each chapter has its own subdirectory, with VCL and CLX
subdirectories under it. In turn, the VCL and CLX subdirectories have a separate subdirectory
for each sample application.
In addition to a subdirectory for each chapter, there is a separate subdirectory named
Components, which contains the data-aware component descendents mentioned earlier.
The Data subdirectory contains the conman.gdb data file used in a number of the sample
applications and the SQL script file (conman.sql) used to create the database.
If you maintain this directory structure on your own drive, the sample programs should all run
fine out of the box. They are set up to access the CONMAN database using the relative path
..\..\..\Data\conman.gdb. If you have trouble running the sample programs, you might
want to modify them to provide a complete path to the data, such as D:\Data\conman.gdb.
dbExpress
dbExpress is Borland’s newest database-access technology, supported both by Delphi and
Kylix. Several database access technologies are supported by Delphi in previous releases,
including BDE, ADO, and IBX. With these three technologies, you may wonder why we need
a new one. dbExpress has a number of exciting characteristics, including
• Cross-platform Whereas BDE and ADO are specific to the Windows platform,
dbExpress currently operates under Windows and Linux (the two platforms that
Delphi/Kylix support). If Borland ever decides to support another platform, such as Mac,
BE, or what have you, dbExpress will be there also.
• Low overhead dbExpress is a thin layer over the underlying database engine’s API. For
this reason, it adds very little overhead to database operations.
• High-performance Largely because of its low overhead, dbExpress is extremely high-
performance. It is designed to work in conjunction with Delphi’s client dataset technology.
6
DELPHI/KYLIX DATABASE DEVELOPMENT
• Easy to distribute Again largely because of its low overhead, dbExpress applications
are easy to redistribute. A typical multitier application needs to deploy MIDAS.DLL and
a dbExpress driver for the back-end database, which commonly weighs in at around
150KB. Contrast this to the BDE’s 10MB footprint.
1
Database Connections
IN THIS CHAPTER
• Connecting to and Disconnecting from a
Database 8
• Transaction Support 37
In this chapter, I’ll show you how to connect to and disconnect from a database using
dbExpress, as well as how to manage the connection after you’ve established it.
NOTE
Just because you have the rights to connect to a database doesn’t mean you have the
right to actually do anything with the database. You might be prevented from creating
new tables, modifying data, or even viewing existing data.
Property Description
Connected A Boolean property that you can set to True to try connecting to
a database, or set to False to disconnect.
ConnectionName Used to establish a named connection. Setting ConnectionName
automatically sets DriverName, GetDriverFunc, LibraryName,
Params, and VendorLib. See the section titled “Named
Connections” for more information on setting up a named con-
nection.
DriverName Indicates the type of database you are connecting to, such as
InterBase, Oracle, and so on.
GetDriverFunc Specifies the name of the function exported by the dbExpress dri-
ver that provides access to the driver.
KeepConnection When True, keeps the connection to the database, even when
there are no active datasets for the connection. When False,
drops the connection as soon as all active datasets are closed.
LibraryName Specifies the name of the dbExpress database driver, such as
dbexpint.dll.
Establishing and Using Database Connections
9
ESTABLISHING AND
USING DATABSE
CONNECTIONS
LoadParamsOnConnect When True, dbExpress loads the DriverName, GetDriverFunc,
LibraryName, Params, and VendorLib from the
dbxconnections.ini configuration file at runtime. When False, all
properties must be set at design time.
LoginPrompt When True, dbExpress prompts the user for username and
password information when connecting to the database. When
False, the application must supply username and password
information directly, through the Params property.
Params Enables you to set database-specific parameters at design time or
at runtime. See the section “Setting Database Parameters” for
more information.
TableScope Specifies the types of information returned by GetTableNames.
This property is discussed in more detail in the section
“Retrieving Database Metadata.”
VendorLib Refers to the database vendor client library used for connecting
to the database server. For example, InterBase supplies the
gds32.dll client-side library.
or
SQLConnection1.Open;
Both of these statements do the same thing, and it is up to you to decide whether you prefer to
set a property or call a method to connect to the database.
The properties you set depend on whether you elect to use a named connection or an unnamed
connection. Both connection types are discussed in the following two sections.
Named Connections
Named connections refer to the fact that you set the properties for a connection in the file
dbxconnections.ini, and give the properties a name. For example, the following example
illustrates how you might set up a named connection for an accounting package:
Chapter 1
10
[Accounting]
BlobSize=-1
CommitRetain=False
Database=D:\InterbaseData\Accounting.gdb
DriverName=Interbase
LocaleCode=0
Password=masterkey
RoleName=Admin
ServerCharSet=ASCII
SQLDialect=1
Interbase TransIsolation=ReadCommited
User_Name=sysdba
WaitOnLocks=True
There are actually two places that you can establish a named connection: on your development
machine, or on the end user’s machine. To create a named connection on your development
machine, drop a TSQLConnection component onto a form or data module. Either double-click
the component, or right-click the component and select Edit Connection Properties from
the pop-up menu. The dbExpress Connection Editor, shown in Figure 1.1, appears.
FIGURE 1.1
The Connection Editor helps you to easily create named connections.
On the left side of the Connection Editor, you see a Driver Name combo box and a
Connection Name list box. By default, the list box shows all existing named connections on
your development machine. If you only want to see named connections for InterBase, Oracle,
or another database server, select the appropriate server from the Driver Name combo box.
Click a connection name in the list box to view the settings for that named connection, or click
the Add Connection button (which looks like a plus sign) on the toolbar to create a new named
connection.
Establishing and Using Database Connections
11
The dbExpress Connection Editor displays the settings for the selected, named connection on 1
the right side in the Connection Settings section. You should set the appropriate settings for
ESTABLISHING AND
USING DATABSE
your database engine. For example, with an InterBase connection, you want to at least set the
CONNECTIONS
Database setting. You might also want to enter values for RoleName, SQLDialect, and User_Name.
For an Oracle connection, you probably want to set the Database, and you might also want to
set values for User_Name.
CAUTION
Although you can set the Password in a named connection, I would generally advise
you not to. If you’re working with a production database, anyone could open the
Connection Editor (or simply view dbxconnections.ini) on your computer to determine
the password for a given database. For this reason, you usually want to provide the
database password at runtime.
If you want your end users to be able to create their own named connections, you should
redistribute dbxconnections.ini along with your application. Your users can either edit
dbxconnections manually, or you can write and redistribute a utility program similar to the
dbExpress Connection Editor that assists your end users in creating named connections.
Unnamed Connections
Named connections are useful in applications that support a number of different database
servers. However, many database applications are written to work with a single database backend.
For example, you might distribute an application on CD that ships with a precreated InterBase
database, such as a parts listing or customer list. In these cases, your end users have no need to
create their own databases. They only need to access the database that you provide on the CD.
For these situations, a named connection is unnecessary. Instead, you set the connection
properties at design time (excluding User_Name and Password, if your application requires
the user to log in at runtime).
You can also access the Params property at runtime to set database parameters, like this:
SQLConnection1.Params.Values[‘Database’] := ‘D:\Interbase\LocalDatabase.gdb’;
For an InterBase connection, you can specify the database in one of three ways: as a local
path, as a UNC path, or as a TCP path. The following are examples of these three constructs,
respectively:
D:\Interbase\LocalDatabase.gdb
\\SERVER\D:\Data\RemoteDatabase.gdb
192.168.0.1:D:\Data\RemoteDatabase.gdb
It is generally advisable to use either a UNC path or a TCP path when connecting to the database
because InterBase has difficulties connecting to a database using a local path under certain
conditions (such as connecting to a database from within a service application). You can
connect to a local database with a TCP path by using 127.0.0.1 as the IP address, like this:
127.0.0.1:D:\Interbase\LocalDatabase.gdb
Controlling Login
As indicated previously, the connection’s LoginPrompt property determines whether
VCL/DataCLX automatically prompts the user for a username and password at runtime. If you
set LoginPrompt to True, the default connection dialog is displayed at runtime, as shown in
Figure 1.2.
You can override the default login dialog and provide your own means of retrieving the
username and password by handling the connection’s OnLogin event, and setting the appropriate
parameters there. The following code snippet shows how:
Establishing and Using Database Connections
13
ESTABLISHING AND
var
USING DATABSE
CONNECTIONS
TheUserName: string;
ThePassword: string;
begin
// Display a custom dialog to retrieve TheUserName and ThePassword
LoginParams.Values[szUSERNAME] := TheUserName;
LoginParams.Values[szPASSWORD] := ThePassword;
end;
FIGURE 1.2
Delphi’s default Database Login dialog.
or
SQLConnection1.Close;
The method you use depends on whether you prefer to set a property or call a method to
disconnect from the database.
CAUTION
You should not set KeepConnection to False in cases where the connection takes a
long time to establish, or in applications where you frequently open and close datasets.
Repeated connecting to and disconnecting from a database (especially in situations
where it takes a long time to connect) can severely impact program performance.
Event Description
AfterConnect Fires after the database connection has been successfully established.
AfterDisconnect Fires after the database connection has been dropped.
BeforeConnect Fires immediately before the connection to the database is
attempted. You can raise an exception in this event handler to prevent
the connection from being established.
BeforeDisconnect Fires immediately before the database connection is dropped. You
can raise an exception in this event handler to prevent the connection
from being dropped.
OnLogin Fires before the connection is made so that you can provide a
username and password at runtime.
Listing 1.1 contains the source code for an application that demonstrates when, and under what
circumstances, these events fire. It also shows how you can prevent the user from connecting to
or disconnecting from the database.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, DBXpress,
ExtCtrls, DB, SqlExpr, QStdCtrls, QExtCtrls;
Establishing and Using Database Connections
15
ESTABLISHING AND
type
USING DATABSE
CONNECTIONS
TfrmMain = class(TForm)
conn: TSQLConnection;
pnlClient: TPanel;
pnlBottom: TPanel;
btnConnect: TButton;
btnDisconnect: TButton;
Label1: TLabel;
lbEvents: TListBox;
grpOptions: TGroupBox;
cbAllowConnect: TCheckBox;
cbAllowDisconnect: TCheckBox;
procedure connAfterConnect(Sender: TObject);
procedure connAfterDisconnect(Sender: TObject);
procedure connBeforeConnect(Sender: TObject);
procedure connBeforeDisconnect(Sender: TObject);
procedure connLogin(Database: TSQLConnection;
LoginParams: TStrings);
procedure btnConnectClick(Sender: TObject);
procedure btnDisconnectClick(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
private
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
on E: Exception do
lbEvents.Items.Add(E.Message);
end;
lbEvents.Items.Add(‘---End Open---’);
lbEvents.Items.Add(‘’);
end;
on E: Exception do
Establishing and Using Database Connections
17
ESTABLISHING AND
USING DATABSE
lbEvents.Items.Add(E.Message);
CONNECTIONS
end;
lbEvents.Items.Add(‘---End Close---’);
lbEvents.Items.Add(‘’);
end;
end.
Notice in the code that the BeforeConnect and BeforeDisconnect event handlers call Abort if
the appropriate check box is not selected. btnConnectClick and btnDisconnectClick check
for an EAbort (or other exception), and display an appropriate message in the list box if the
connect or disconnect attempt fails for any reason.
Figure 1.3 shows what the application looks like at runtime. The list box is filled with informative
text that illustrates exactly when, and in what order, the connection events fire.
FIGURE 1.3
The Events application makes it easy to understand connection events.
Chapter 1
18
GetTableNames
You can call GetTableNames to retrieve a list of the tables in the database, including user
tables, system tables, views, and synonyms (Oracle databases only).
TSQLConnection.GetTableNames is defined in sqlexpr.pas like this:
procedure GetTableNames(List: TStrings; SystemTables: Boolean = False);
The first parameter specifies the string list in which the table names are returned. Any existing
strings in the list are cleared. The second parameter indicates whether to return only system
tables.
If the SystemTables parameter is set to True, only system tables are added to the list, regardless
of the current setting for the TableScope property (shown in Table 1.4). If SystemTables is
False, TableScope controls the types of tables that are added to the list.
Property Description
TsSynonym Synonyms
TsSysTable System tables
TsTable Normal, user-defined tables
TsView Views
GetFieldNames
GetFieldNames is used to retrieve the names of all fields defined for a given table or view.
GetFieldNames takes two parameters, and is defined like this:
Pass the table name for which you want to retrieve field names in the first parameter. The 1
second parameter indicates the list in which the resulting field names are to be loaded. Any
ESTABLISHING AND
USING DATABSE
existing strings in the list are deleted.
CONNECTIONS
GetFieldNames(‘CONTACTS’, ListBox1.Items);
GetIndexNames
Similar to GetFieldNames, GetIndexNames is used to retrieve the names of all indexes defined
on a given table. GetIndexNames is defined as follows:
procedure GetIndexNames(const TableName: string; List: TStrings);
As with GetFieldNames, TableName represents the table that you want to get index names for.
List indicates the list in which the resulting index names are to be loaded. Any existing strings
in the list are deleted.
GetIndexNames(‘CONTACTS’, ListBox1.Items);
GetProcedureNames
To retrieve a list of stored procedures in a database, call the GetProcedureNames method.
GetProcedureNames is defined like this:
Upon return from the procedure, List contains the list of stored procedure names. Any
information previously stored in the list is deleted.
GetProcedureNames(ListBox1.Items);
GetProcedureParams
GetProcedureParams returns a list of parameters for a given stored procedure. It is defined
like this:
procedure GetProcedureParams(ProcedureName:string; List: TList);
The first parameter, ProcedureName, specifies the name of the stored procedure that you want
to retrieve parameter names for. The second parameter refers to a precreated TList in which
the procedure parameters are returned. Upon return from the procedure, List contains a list of
parameters for the stored procedure.
List itself is not directly usable. You should call the helper function, LoadParamListItems, to
convert the TList to a TParams object, which is a much easier structure to inspect.
The following code snippet shows how to correctly retrieve parameters for the stored procedure
named CONTACTSBYSTATE.
Chapter 1
20
var
listParams: TList;
Params: TParams;
begin
listParams := TList.Create;
try
SQLConnection1.GetProcedureParams(‘CONTACTSBYSTATE’, listParams);
Params := TParams.Create;
try
LoadParamListItems(Params, listParams);
Note the call to FreeProcParams at the end of this code snippet. FreeProcParams frees and
nils the listParams TList, so you don’t need to explicitly free the list.
Listing 1.2 shows the complete source code for a sample application that uses TSQLConnection
to retrieve table, field, index, procedure, and parameter names from an InterBase database.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QExtCtrls,
DBXpress, QComCtrls, QStdCtrls, DB, SqlExpr;
type
TfrmMain = class(TForm)
conn: TSQLConnection;
pnlBottom: TPanel;
lblConnection: TLabel;
pnlClient: TPanel;
grpTableScope: TGroupBox;
btnTable: TCheckBox;
btnView: TCheckBox;
btnSynonym: TCheckBox;
btnSystemTable: TCheckBox;
Establishing and Using Database Connections
21
ESTABLISHING AND
btnConnect: TButton;
USING DATABSE
CONNECTIONS
btnDisconnect: TButton;
Label4: TLabel;
Label5: TLabel;
OpenDialog1: TOpenDialog;
PageControl1: TPageControl;
tabTables: TTabSheet;
tabProcedures: TTabSheet;
cbProcedure: TComboBox;
Label2: TLabel;
Label3: TLabel;
cbTable: TComboBox;
lbFields: TListBox;
lbIndexes: TListBox;
Label1: TLabel;
Label6: TLabel;
Label7: TLabel;
lvParameters: TListView;
procedure btnConnectClick(Sender: TObject);
procedure cbTableClick(Sender: TObject);
procedure btnDisconnectClick(Sender: TObject);
procedure cbProcedureClick(Sender: TObject);
procedure connAfterConnect(Sender: TObject);
procedure connAfterDisconnect(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
private
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
else
conn.TableScope := conn.TableScope - [TableScope];
end;
begin
if OpenDialog1.Execute then begin
conn.Params.Values[‘Database’] := OpenDialog1.FileName;
CheckScope(btnTable.Checked, tsTable);
CheckScope(btnView.Checked, tsView);
CheckScope(btnSynonym.Checked, tsSynonym);
CheckScope(btnSystemTable.Checked, tsSysTable);
conn.Open;
end;
end;
lvParameters.Items.BeginUpdate;
try
Establishing and Using Database Connections
23
ESTABLISHING AND
lvParameters.Items.Clear;
USING DATABSE
CONNECTIONS
for Index := 0 to Params.Count - 1 do begin
Param := Params[Index];
ListItem := lvParameters.Items.Add;
ListItem.Caption := Param.Name;
case Param.DataType of
ftUnknown: ListItem.SubItems.Add(‘Unknown’);
ftString: ListItem.SubItems.Add(‘String’);
ftSmallint: ListItem.SubItems.Add(‘Smallint’);
ftInteger: ListItem.SubItems.Add(‘Integer’);
ftWord: ListItem.SubItems.Add(‘Word’);
ftBoolean: ListItem.SubItems.Add(‘Boolean’);
ftFloat: ListItem.SubItems.Add(‘Float’);
ftCurrency: ListItem.SubItems.Add(‘Currency’);
ftBCD: ListItem.SubItems.Add(‘BCD’);
ftDate: ListItem.SubItems.Add(‘Date’);
ftTime: ListItem.SubItems.Add(‘Time’);
ftDateTime: ListItem.SubItems.Add(‘DateTime’);
ftBytes: ListItem.SubItems.Add(‘Bytes’);
ftVarBytes: ListItem.SubItems.Add(‘VarBytes’);
ftAutoInc: ListItem.SubItems.Add(‘AutoInc’);
ftBlob: ListItem.SubItems.Add(‘Blob’);
ftMemo: ListItem.SubItems.Add(‘Memo’);
ftGraphic: ListItem.SubItems.Add(‘Graphic’);
ftFmtMemo: ListItem.SubItems.Add(‘FmtMemo’);
ftParadoxOle: ListItem.SubItems.Add(‘ParadoxOle’);
ftDBaseOle: ListItem.SubItems.Add(‘DBaseOle’);
ftTypedBinary: ListItem.SubItems.Add(‘TypedBinary’);
ftCursor: ListItem.SubItems.Add(‘Cursor’);
ftFixedChar: ListItem.SubItems.Add(‘FixedChar’);
ftWideString: ListItem.SubItems.Add(‘WideString’);
ftLargeint: ListItem.SubItems.Add(‘Largeint’);
ftADT: ListItem.SubItems.Add(‘ADT’);
ftArray: ListItem.SubItems.Add(‘Array’);
ftReference: ListItem.SubItems.Add(‘Reference’);
ftDataSet: ListItem.SubItems.Add(‘DataSet’);
ftOraBlob: ListItem.SubItems.Add(‘OraBlob’);
ftOraClob: ListItem.SubItems.Add(‘OraClob’);
ftVariant: ListItem.SubItems.Add(‘Variant’);
ftInterface: ListItem.SubItems.Add(‘Interface’);
ftIDispatch: ListItem.SubItems.Add(‘IDispatch’);
ftGuid: ListItem.SubItems.Add(‘Guid’);
ftTimeStamp: ListItem.SubItems.Add(‘TimeStamp’);
Chapter 1
24
lblConnection.Font.Color := clGreen;
lblConnection.Caption := conn.Params.Values[‘Database’];
conn.GetTableNames(cbTable.Items, btnSystemTable.Checked);
conn.GetProcedureNames(cbProcedure.Items);
cbTable.ItemIndex := 0;
cbTableClick(cbTable);
PageControl1.ActivePage := tabTables;
ActiveControl := cbTable;
cbProcedure.ItemIndex := 0;
cbProcedureClick(cbProcedure);
end;
lblConnection.Font.Color := clRed;
Establishing and Using Database Connections
25
ESTABLISHING AND
USING DATABSE
lblConnection.Caption := ‘Not connected’;
CONNECTIONS
cbTable.Items.Clear;
cbProcedure.Items.Clear;
lbFields.Items.Clear;
lbIndexes.Items.Clear;
end;
end.
Figure 1.4 shows table, field, and index names returned from the CONMAN database.
FIGURE 1.4
Column and field lists for the CONTACTS table.
Figure 1.5 shows a list of procedure parameters for the CONTACTSBYSTATE stored procedure.
Most of the code in Listing 1.2 is fairly straightforward. However, there are two items of
interest that I would like to point out.
First, the cbProcedureClick method illustrates how you can loop through the parameters for a
stored procedure to determine their name, type, and other attributes.
Chapter 1
26
FIGURE 1.5
Parameter names and types for the long list of parameters output from the CONTACTSBYSTATE procedure.
Second, there is a bug in VCL/DataCLX that effectively prevents you from retrieving table,
view, and system table metadata together. If you check Tables, Views, and System Tables at the
same time, GetTableNames (in the connAfterConnect method) does not return any information.
Be aware of this in your own applications. If you need to retrieve all three types of information,
you can do something like the following:
var
SL: TStringList;
begin
SL := TStringList.Create;
try
SQLConnection1.TableScope := [tsTable, tsView];
SQLConnection1.GetTableNames(ListBox1.Items, False);
SQLConnection1.GetTableNames(SL, True);
ListBox1.Items.AddStrings(SL);
finally
SL.Free;
end;
end;
This code retrieves only table and view information first, putting the results into a list box.
Next, it retrieves only system tables, putting the results into a temporary string list. Finally, it
adds the strings from the temporary string list into the list box. So, the list box contains tables,
views, and system tables.
As you can see from this discussion, the schema information returned from TSQLConnection is
extremely basic. Other than for stored procedure parameters, the only data that
Establishing and Using Database Connections
27
TSQLConnection returns for tables, fields, indexes, and stored procedures is their names. In the 1
following chapter, I’ll show you how to retrieve much more detailed schema information from
ESTABLISHING AND
USING DATABSE
a database.
CONNECTIONS
Executing DDL and DML Statements
The most common operations that you will perform on a database are DDL (Data Definition
Language) and DML (Data Manipulation Language) statements. You can execute DDL and
DML statements directly through a TSQLConnection. DML statements that return a cursor (that
is, SQL SELECT statements) require a dataset component in addition to the TSQLConnection, as
you’ll see in Chapter 3, “Client Dataset Basics.”
DDL Commands
DDL commands are statements that operate on the database schema, rather than on the data
itself. In the previous section, I showed you how to retrieve information about the database
schema. In this section, I’ll show you how to change the database schema.
TSQLConnection provides a method named ExecuteDirect, which you use to execute DDL
commands. ExecuteDirect takes a single parameter, which is the SQL command to execute.
It returns 0 on success, or a dbExpress error code on failure. dbExpress error codes can be
found in the file DBXpress.pas, which is included with Delphi.
The Direct part of the name ExecuteDirect comes from the fact that the statement is sent
directly to the database. The statement is not prepared before it is executed, and it cannot
contain any parameters. (Parameterized SQL statements are discussed in the section titled
“Parameterized SQL Statements” later in this chapter.)
NOTE
Some databases don’t support direct SQL execution. On those databases, dbExpress
will internally prepare the SQL statement, and then execute it.
Creating a Table
One of the simplest and most useful DDL commands you can issue is the command to create a
new table in the database. Assume that you want to create a table named EMPLOYEE with the
structure shown in Table 1.5. You would issue the following statement:
SQLConnection1.ExecuteDirect(‘CREATE TABLE EMPLOYEE (EMPNO INTEGER, ‘ +
‘NAME VARCHAR(30), ‘HIREDATE DATE, SALARY DOUBLE PRECISION’);
Chapter 1
28
Creating a Database
You cannot use ExecuteDirect to create a new InterBase database. The following line of code
does not work:
SQLConnection1.ExecuteDirect(‘CREATE DATABASE ‘’C:\NewData.gdb’’’);
If you try to execute this code, you receive the following exception:
Cannot prepare a CREATE DATABASE/SCHEMA statement.
The InterBase client doesn’t allow direct execution of DDL or DML, so the dbExpress driver
attempts to prepare, and then to execute the CREATE DATABASE statement. Because InterBase
doesn’t allow a CREATE DATABASE statement to be prepared, an exception is raised.
If you want your applications to be capable of creating new InterBase databases, you need to
find another way to do it. One way is to keep an empty copy of the database in your program
directory and copy it when the user creates a new database.
I have found the following to be useful in my own experience: Save a copy of the empty data-
base as a resource in your application. When the user creates a new database, save the resource
to disk under the filename that the user selects. The following explanation shows how this can
be done in a Windows environment. Note that this is Windows specific and can’t be used for a
Linux or cross-platform application.
First, you want to create a resource script file that turns your empty database into a resource.
Assuming that you have an empty copy of your database in the D:\Interbase directory, the
following script file creates a resource named EMPTYDB:
EMPTYDB RCDATA DISCARDABLE “D:\Interbase\Empty.GDB”
Save this script as EmptyDB.RC. To create a .RES file from the resource script, execute the
following command:
BRCC32 EMPTYDB.RC
Establishing and Using Database Connections
29
This creates the file EMPTYDB.RES, which can be included in your application. Somewhere 1
in your program code, include the code from Listing 1.3.
ESTABLISHING AND
USING DATABSE
CONNECTIONS
LISTING 1.3 Code to Create an Empty Database from a Resource
{$R EmptyDB.RES} // Add the empty database to the program executable
Now when you want to create a new database in your application, you simply call
CreateDatabase, passing the complete pathname of the database, like this:
CreateDatabase(‘C:\NewDatabase.gdb’);
As you’re working on your application, if you change the database schema, you must remem-
ber to reissue the BRCC32 command (shown previously) to re-create the EMPTYDB.RES file.
Otherwise, you wind up with an incorrect, empty database inside your application.
Another way that you could handle this situation is to make direct, low-level calls to the data-
base API from within your application. This approach has the benefit of being cross-platform,
but you must learn the appropriate API commands for the database backend in question.
DML Commands
Whereas DDL commands are used to define the database schema, DML commands are used to
manipulate (read, write, and update) the data in the database.
Chapter 1
30
Note in the preceding code snippet that the ‘N’ is surrounded by quotes. A better approach to
quoting string constants manually is to use the RTL function, QuotedStr. QuotedStr takes a
string parameter and returns the string within quotes. One of the main reasons for using
QuotedStr is that it correctly handles string constants that contain quotes. For example, given
the name O’Toole, would you know how to quote it manually? The correct way is
‘O’’Toole’
Using QuotedStr, you don’t have to worry about how to correctly quote a string. The preceding
ExecuteDirect call becomes
Again, QuotedStr could be used here instead of quoting the strings manually.
For each employee that you want to add, you would create and execute a similar SQL statement.
Parameterized SQL statements enable you to create a sort of statement template, in which you
can easily enter the appropriate values for each statement before executing. For the EMPLOYEE
table, the SQL INSERT statement becomes the following:
INSERT INTO EMPLOYEE VALUES (:EmpNumber, :Name, :Hired, :Salary)
Each value to be inserted into the database is replaced with a parameter. Parameters are easily
detectable because they start with a colon. Internally, the dbExpress components parse the SQL
statement, and convert the parameters into question marks that the core dbExpress code
supports. In turn, the dbExpress driver replaces the question marks with the parameter markers,
which are supported by the backend database engine.
Establishing and Using Database Connections
31
ESTABLISHING AND
INSERT INTO EMPLOYEE VALUES (?, ?, ?, ?)
USING DATABSE
CONNECTIONS
The dbExpress driver may replace the question marks with some other construct that is specific
to the database server.
NOTE
Parameters do not need to have the same name as the underlying column in the
table. Notice in the preceding code snippet that I named the parameter EmpNumber
instead of EmpNo, and Hired instead of DateHired.
After you create the SQL statement, you need to fill in the value of each parameter before
executing. The following code snippet shows how this is done:
Params := TParams.Create(nil);
try
// Create the parameters
Params.CreateParam(ftInteger, ‘EmpNumber’, ptInput);
Params.CreateParam(ftString, ‘Name’, ptInput);
Params.CreateParam(ftSQLDateTime, ‘Hired’, ptInput);
Params.CreateParam(ftFloat, ‘Salary’, ptInput);
You might be wondering why you would ever want to do this rather than simply executing
each statement directly. After all, creating the parameters and assigning them takes a lot more
effort. In addition, time tests on my development machine show that the second method takes
about twice as long as the first.
In cases where I need to repeatedly execute the same statement, I still prefer using a
parameterized query for the following reasons:
Chapter 1
32
• Simplicity. Given a long, complicated SQL statement, it’s often difficult to form the SQL
statement manually. Getting the quotes lined up correctly can be prone to errors, considering
that you must double up on single quotes in the Pascal language.
• Robustness. Say for the sake of argument that one of the employee names has a quote in
it, such as Frank O’Donnell. If you don’t use parameters, the quote looks as if it were the
terminating quote on the name. The parameterless SQL statement would look like this:
INSERT INTO EMPLOYEE VALUES (123, ‘Frank O’Donnell’, ‘5/15/1994’, 35000)
In this case, the name appears to be Frank O, and the following character (D) in the
statement is in error. You can solve this problem by double quoting the name, like this:
INSERT INTO EMPLOYEE VALUES (123, ‘Frank O’’Donnell’, ‘5/15/1994’, 35000)
• When using parameters, a true relational database can compile the prepared statement,
and then use that compiled version for all subsequent calls to the SQL statement. When
repeatedly issuing the same SQL statement, this more than compensates for the additional
time required to prepare the SQL statement.
The use of QuotedStr to automatically quote strings helps to alleviate the first two “problems.”
The Execute method, which I discussed in the previous section, actually takes a third parameter:
a pointer to a result set.
function Execute(const SQL: string; Params: TParams;
ResultSet: Pointer = nil): Integer;
If you don’t pass a third parameter to Execute, it defaults to nil, which means that the statement
doesn’t return a result set.
NOTE
If you pass a nil value for the ResultSet and the SQL command actually does return
a result set, the result set is simply discarded. No exception is raised, and no error is
returned.
Establishing and Using Database Connections
33
The following code snippet shows one way to execute an SQL statement that returns a result 1
set. In the following chapter, you’ll see easier ways to do this.
ESTABLISHING AND
USING DATABSE
CONNECTIONS
SQLDataSet1 := TSQLDataSet.Create(nil);
try
SQLConnection1.Execute(‘SELECT * FROM EMPLOYEE’, nil, SQLDataSet1);
The preceding code also shows the correct way to execute a nonparameterized SELECT statement:
Simply pass nil for Params.
The following example program, DDLSQL, shows how to create a test table, fill it with data,
and destroy the table programmatically. It also demonstrates how to retrieve data from the table
using the Execute method. Listing 1.4 shows the complete code for DDLSQL.
interface
uses
SysUtils, Types, Classes, QGraphics, QControls, QForms, QDialogs,
QStdCtrls, QExtCtrls, DBXpress, DB, SqlExpr;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
conn: TSQLConnection;
btnCreate: TButton;
btnPopulate: TButton;
btnConnect: TButton;
btnDelete: TButton;
btnDisconnect: TButton;
btnParameters: TButton;
btnDrop: TButton;
lbOutput: TListBox;
procedure btnCreateClick(Sender: TObject);
procedure btnConnectClick(Sender: TObject);
procedure btnPopulateClick(Sender: TObject);
procedure btnDisconnectClick(Sender: TObject);
procedure btnDeleteClick(Sender: TObject);
Chapter 1
34
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
ESTABLISHING AND
USING DATABSE
procedure TfrmMain.btnDeleteClick(Sender: TObject);
CONNECTIONS
begin
conn.ExecuteDirect(‘DELETE FROM TESTING WHERE NAME = “Tina”’);
btnDisconnect.Enabled := True;
lbOutput.Items.Add(‘Connected’);
end;
lbOutput.Items.Add(‘Disconnected’);
end;
end.
FIGURE 1.6
DDLSQL after running the gamut of buttons.
Establishing and Using Database Connections
37
Transaction Support 1
ESTABLISHING AND
USING DATABSE
Most SQL databases, with the notable exception of versions of MySQL prior to 3.23, provide
CONNECTIONS
support for transactions. A transaction must satisfy four criteria, which are called the ACID
properties of transactions. Specifically, transactions are
• Atomic. The transaction must either succeed or fail as a whole. It is not acceptable for
part of the transaction to succeed and part of it to fail.
• Consistent. After a transaction finishes, the data must be left in a consistent state. All
data must adhere to current referential integrity constraints.
• Isolated. Changes made in one transaction must not be visible to another transaction until
the transaction is committed.
• Durable. Once a transaction is committed, its changes must be permanent. Nothing,
including a system crash, must alter the effects of the committed transaction.
Transactions are most easily described with an example, so I’ll go through the most often-cited
example of transaction support.
Imagine a bank with a database that contains clients and account information. John Q.
Customer has both a savings account and a checking account at the bank. He makes a trip to
his local ATM, and decides to transfer $100 from his savings account to his checking account.
In SQL terms, this constitutes two operations: one INSERT statement to record a withdrawal
from the savings account, and a second INSERT statement to record the deposit into the checking
account. Assuming the ATM software is written using Delphi and dbExpress, it might contain
some code similar to the following:
procedure TransferFunds(FromAccountID: Integer; ToAccountID: Integer;
Amount: Double);
var
SQL: string;
Params: TParams;
begin
Params := TParams.Create;
try
SQL := ‘INSERT INTO ACCOUNTDETAIL (ACCOUNTID, TRANSDATE, AMOUNT) ‘ +
VALUES (:AccountID, :TransDate, :TransAmount)’;
Params.ParamByName(‘AccountID’).Value := FromAccountID;
Params.ParamByName(‘TransDate’).Value := Date;
Params.ParamByName(‘TransAmount’).Value := -Amount;
SQLConnection1.Execute(SQL, Params);
Params.ParamByName(‘AccountID’).Value := ToAccountID;
Params.ParamByName(‘TransAmount’).Value := Amount;
Chapter 1
38
SQLConnection1.Execute(SQL, Params);
finally
Params.Free;
end;
end;
Conceptually, this code snippet creates a withdrawal for the originating account, and a deposit
for the target account. At first, it might seem like there’s nothing wrong with this code, but let’s
take a look at the possibilities.
What happens if a power outage, connection failure, or some other catastrophic failure occurs
between the time the withdrawal is recorded and the time the deposit is recorded? The money
would be deducted from the savings account, but it would never get added to the checking
account (which makes Mr. Customer a very unhappy, quite possibly former, customer).
If the code is reversed to create the deposit before the withdrawal, the opposite can happen:
The deposit gets recorded, but the withdrawal never occurs (which makes Mr. Customer very
happy and $100 richer, but the bank is shorted).
Clearly, the ATM software needs to have some assurance that either the withdrawal and the
deposit both occur, or that neither of them occur. This is what transactions are designed to handle.
The following few sections describe how to detect whether a given database supports transactions,
how to start (and subsequently end) a transaction, and how to handle multiple (and nested)
transactions.
SQLConnection1.Open;
...
if SQLConnection1.TransactionsSupported then begin
// Go ahead with transaction code
end else begin
// Transactions not supported - proceed with alternate code or bail out
end;
Establishing and Using Database Connections
39
Starting a Transaction 1
ESTABLISHING AND
When you’ve determined that the database supports transactions, you can start a transaction.
USING DATABSE
CONNECTIONS
To begin, call the method TSQLConnection.StartTransaction. StartTransaction is defined
like this:
procedure StartTransaction( TransDesc: TTransactionDesc);
TTransactionDesc is a record that describes the transaction in detail. Its definition is shown
here:
TTransactionDesc = packed record
TransactionID : LongWord; { Transaction id }
GlobalID : LongWord; { Global transaction id }
IsolationLevel : TTransIsolationLevel; {Transaction Isolation level}
CustomIsolation : LongWord; { DB specific custom isolation }
end;
Table 1.6 shows the meaning of the individual fields in the TTransactionDesc record.
Field Definition
TransactionID User-defined, local transaction number that uniquely identifies the
transaction for purposes of this application.
GlobalID Used for Oracle transactions to define a transaction number that
must be unique across the entire Oracle database.
IsolationLevel Used to specify how this transaction reacts to other transactions.
Valid values for this field are listed in Table 1.7.
CustomIsolation Identifies the custom isolation level when IsolationLevel is set to
xilCUSTOM. No dbExpress drivers currently support this.
Table 1.7 shows the valid settings for TTransactionDesc’s IsolationLevel field.
Committing a Transaction
After you have issued the appropriate SQL commands inside a transaction, you want to commit
the transaction. This ends the transaction, and saves any changes made during that transaction.
Committing a transaction is as easy as calling TSQLConnection.Commit, which the following
line of code illustrates:
SQLConnection.Commit(TransDesc);
Multiple Transactions
You are not limited to just one transaction at a time. Transactions can be nested or overlapped.
Figure 1.7 shows what nested and overlapped transactions look like at a conceptual level.
Establishing and Using Database Connections
41
ESTABLISHING AND
USING DATABSE
CONNECTIONS
FIGURE 1.7
Transactions may be nested or overlapped.
Not all databases support multiple transactions. Unfortunately, to determine whether a database
supports multiple transactions, you can’t check a simple property. TSQLConnection contains a
private field named FSupportsMultiTrans, but there is no public access to it. All is not lost,
however, as you can use the following function to retrieve the value of this property:
function SupportsMultiTrans(conn: TSQLConnection): Boolean;
var
Supported: LongBool;
PropSize: SmallInt;
begin
conn.MetaData.GetOption(eMetaSupportsTransactions, @Supported,
SizeOf(Integer), PropSize);
Result := Supported;
end;
If the database does not support multiple transactions, you want to refrain from calling
StartTransaction while a transaction is active. You can test to see whether a transaction is
currently active by checking the InTransaction property, like this:
if not SQLConnection1.InTransaction then
// Safe to start a transaction
If the database supports multiple transactions, you can nest them, as the following code snippet
shows:
var
TransOuter: TTransactionDesc;
TransInner: TTransactionDesc;
begin
TransOuter.TransactionID := 1;
TransOuter.IsolationLevel := xilREADCOMMITTED;
SQLConnection1.StartTransaction(TransOuter);
try
Chapter 1
42
TransInner.TransactionID := 2;
TransInner.IsolationLevel := xilREADCOMMITTED;
SQLConnection1.StartTransaction(TransInner);
try
// Execute some more SQL statements here
SQLConnection1.Commit(TransInner);
except
SQLConnection1.Rollback(TransInner);
raise;
end;
SQLConnection1.Commit(TransOuter);
except
SQLConnection1.Rollback(TransOuter);
raise;
end;
end;
Note in the preceding code that the transactions are enclosed in try/except blocks. If an
exception occurs while executing the code, the transaction is rolled back.
Listing 1.5 contains the complete source code for an application that demonstrates dbExpress
transaction support (including transaction isolation, committing and rolling back transactions,
and nested transactions).
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QExtCtrls,
DBXpress, QStdCtrls, DB, SqlExpr;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
conn: TSQLConnection;
btnConnect: TButton;
btnDisconnect: TButton;
Establishing and Using Database Connections
43
ESTABLISHING AND
lbOutput: TListBox;
USING DATABSE
CONNECTIONS
btnCommit: TButton;
btnRollback: TButton;
btnMultiLevel: TButton;
btnOverlapping: TButton;
procedure btnConnectClick(Sender: TObject);
procedure btnDisconnectClick(Sender: TObject);
procedure btnCommitClick(Sender: TObject);
procedure btnRollbackClick(Sender: TObject);
procedure btnMultiLevelClick(Sender: TObject);
procedure btnOverlappingClick(Sender: TObject);
procedure connAfterConnect(Sender: TObject);
procedure connAfterDisconnect(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
private
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
if conn.TransactionsSupported then
lbOutput.Items.Add(‘Connection supports transactions’)
else
Chapter 1
44
if SupportsMultiTrans(conn) then
lbOutput.Items.Add(‘Connection supports multiple transactions’)
else
lbOutput.Items.Add(‘Connection does not support multiple transactions’);
end;
ESTABLISHING AND
conn.StartTransaction(TransDesc1);
USING DATABSE
CONNECTIONS
conn.ExecuteDirect(‘UPDATE CONTACTS SET PHONE = “Trans 1” WHERE ID = 3’);
TransDesc2.TransactionID := 2;
TransDesc2.IsolationLevel := xilREADCOMMITTED;
conn.StartTransaction(TransDesc2);
conn.ExecuteDirect(‘UPDATE CONTACTS SET PHONE = “Trans 2” WHERE ID = 2’);
conn.Rollback(TransDesc2);
lbOutput.Items.Add(‘Second transaction rolled back’);
conn.Commit(TransDesc1);
lbOutput.Items.Add(‘First transaction committed’);
end;
conn.StartTransaction(TransDesc3);
conn.ExecuteDirect(‘UPDATE CONTACTS SET PHONE = “Trans 3” WHERE ID = 3’);
TransDesc4.TransactionID := 4;
TransDesc4.IsolationLevel := xilREADCOMMITTED;
conn.StartTransaction(TransDesc4);
conn.ExecuteDirect(‘UPDATE CONTACTS SET PHONE = “Trans 4” WHERE ID = 2’);
conn.Rollback(TransDesc3);
lbOutput.Items.Add(‘Transaction 3 rolled back’);
conn.Commit(TransDesc4);
lbOutput.Items.Add(‘Transaction 4 committed’);
end;
btnOverlapping.Enabled := True;
end;
end.
FIGURE 1.8
Trans shows how to perform nested transactions.
• Some SQL operations are fast, but some are extremely slow. A very complicated SELECT 1
statement that is performed on a large database might take several minutes (or even
ESTABLISHING AND
USING DATABSE
hours) to execute.
CONNECTIONS
• Especially when using TSQLConnection in conjunction with datasets (discussed in
Chapter 3), the connection might run some SQL commands on your behalf. It can be
useful for both learning and debugging purposes to intercept all SQL commands sent to
the database.
In either of these situations, it’s helpful to be able to provide feedback or logging facilities to
the end user of your application (or to yourself) in the form of a log file, database, CodeSite
(from Raize Software, at www.raize.com), or other debugging tool.
CallType is always set to cbTRACE on entry to the callback function. CBInfo is a pointer to
an SQLTRACEDesc record, which is defined like this:
SQLTRACEDesc = packed record { trace callback info }
pszTrace : array [0..1023] of Char;
eTraceCat : TRACECat;
ClientData : Integer;
uTotalMsgLen : Word;
end;
Chapter 1
48
Field Definition
pszTrace The NULL-terminated command that was just passed to or from the
database.
eTraceCat The category of the command just sent or received. Table 1.9 lists the
possible values of this field.
ClientData The user-defined value passed as the second parameter to
SetTraceCallbackEvent.
uTotalMsgLen The length, in characters, of the string contained in pszTrace.
Table 1.9 describes the possible values for the eTraceCat field.
Value Definition
traceQPREPARE A query was sent to the server to prepare.
traceQEXECUTE A query was sent to the server to execute.
traceERROR An error message was returned by the server.
traceSTMT An operation for the database to perform was sent to the server.
traceCONNECT A connect-or disconnect-related operation was sent to the server.
traceTRANSACT A transaction-related operation was sent to the server.
traceBLOB A BLOB-related operation was sent to the server.
traceVENDOR A vendor-specific API call was sent to the server.
traceDATAIN Parameter data was sent to the server during an INSERT or UPDATE
command.
traceDATAOUT Data was retrieved from the server.
traceMISC Any other command not falling under one of the previous categories.
NOTE
Not all of these options are currently supported. Many of them are there for future
expansion. Currently, all vendor calls and executed SQL commands are traced.
Establishing and Using Database Connections
49
ESTABLISHING AND
function SQLCallback(CallType: TRACECat; CBInfo: Pointer): CBRType; stdcall;
USING DATABSE
CONNECTIONS
var
CBI: pSQLTRACEDesc;
begin
CBI := pSQLTRACEDesc(CBInfo);
ShowMessage(CBI.pszTrace);
end;
begin
SQLConnection1.SetTraceCallbackEvent(SQLCallback, 1);
end;
CAUTION
Do not pass 0 as the second parameter to SetTraceCallbackEvent, or your callback
event will never be called and you’ll rack your brains trying to figure out why
callback events are not working.
TSQLMonitor
TSQLMonitor provides a ready-made, easy-to-use mechanism for capturing database events.
You can log database messages to a list box, a log file, or another destination as they occur.
You can also allow messages to accumulate in the monitor’s internal buffer, and dump them to
a file (or other destination) in one fell swoop.
To use a TSQLMonitor, drop it on a form or data module, along with your TSQLConnection
component. To begin logging events, set the monitor’s SQLConnection property to the
TSQLConnection component and set the Active property to True.
There are two ways to monitor database messages: You can elect to log each message as soon
as TSQLMonitor is notified of it, or you can allow the component to buffer up the messages
and save them to a log file or string list at a later time. The following sections explain these
options.
the name of the log file that you want to create, such as C:\TRACE.LOG. Set AutoSave to
True, and the component automatically logs all database messages to the specified filename. If
the file does not exist, it is automatically created. If the file does exist, it is appended to.
The second method of logging messages on the fly is to write an event handler for the OnTrace
or OnLogTrace event. OnTrace is fired as soon as the component receives an indication that a
message has passed between the application and the database server. The event handler looks
like this:
procedure TForm1.SQLMonitor1Trace(Sender: TObject; CBInfo: pSQLTRACEDesc;
var LogTrace: Boolean);
begin
end;
Inside the event handler, you can set LogTrace to False if, for some reason, you don’t want the
message to be saved to the internal list. By default, the message is logged.
OnLogTrace is fired after the message is added to the internal trace list. Its event handler looks
like this:
procedure TForm1.SQLMonitor1LogTrace(Sender: TObject; CBInfo: pSQLTRACEDesc);
begin
end;
The parameters are the same as the first two parameters passed to the OnTrace event handler.
Buffering Messages
Rather than dealing with each individual message as it arrives, you can allow them to be
buffered in an internal list by the TSQLMonitor component. You can then save them to a file or
to a string list at a later time.
To save the list to a file, call the component’s SaveToFile method, like this:
SQLMonitor1.SaveToFile(‘C:\EventList.LOG’);
Alternately, you can save the messages to a string list by accessing the TraceList property,
which is a TStrings object containing the list of messages. By calling methods and/or properties
on TraceList, you can access the individual lines in the list.
set the TSQLMonitor’s Active property to False (assuming that you also have a TSQLMonitor 1
component in your application).
ESTABLISHING AND
USING DATABSE
CONNECTIONS
Another thing you should avoid is accidentally overwriting an existing callback handler by
assigning a new one. To check for an existing callback event (including the presence of a
TSQLMonitor component), you should check the value of the read-only
TSQLConnection.TraceCallbackEvent property to ensure that it is nil.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, DBXpress,
QExtCtrls, SqlExpr, DB, QStdCtrls;
type
TfrmMain = class(TForm)
conn: TSQLConnection;
pnlClient: TPanel;
lbTrace: TListBox;
Label1: TLabel;
btnDump: TButton;
btnConnect: TButton;
btnDisconnect: TButton;
btnExecSQL: TButton;
btnLogTrace: TCheckBox;
monitor: TSQLMonitor;
cbUseCallback: TCheckBox;
procedure btnDumpClick(Sender: TObject);
procedure btnConnectClick(Sender: TObject);
Chapter 1
52
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
cbUseCallbackClick(cbUseCallback);
end;
ESTABLISHING AND
procedure TfrmMain.btnExecSQLClick(Sender: TObject);
USING DATABSE
CONNECTIONS
begin
conn.ExecuteDirect(‘SELECT * FROM CONTACTS’);
end;
monitor.Active := True;
end;
end;
end.
Let’s take a look at the cbUseCallbackClick event handler. If you check the Use Callback
check box, the code disables the monitor. It then calls SetTraceCallbackEvent to set up a
callback procedure to monitor database messages.
When you uncheck the Use Callback check box, the code sets the monitor to active. This
re-establishes the TSQLMonitor component as the feedback mechanism for database messages.
Another point of interest is the MonitorTrace method. If the Log Trace check box is checked,
we capture the message immediately and send it to the list box. Because we’ve handled the
message, there’s no need to buffer it in the monitor’s internal list. For that reason, the code sets
LogTrace to False.
FIGURE 1.9
Feedback logs messages sent to and from the database server.
Establishing and Using Database Connections
55
Summary 1
ESTABLISHING AND
USING DATABSE
This chapter introduced you to the TSQLConnection component, which is used to establish and
CONNECTIONS
maintain a connection to an SQL database. Specifically, you learned:
• You can create either named or unnamed database connections.
• TSQLConnection surfaces a small number of events that are useful in allowing or
preventing connections to, and disconnections from, a database.
• You can easily retrieve schema information (also called metadata) from a database
connection, including table, field, index, and procedure data.
• TSQLConnections can be used to execute both DDL and DML commands against a
database.
• If a database server supports transactions, you can control those transactions through the
connection component.
• Several mechanisms are available for you to report feedback while performing operations
against a database.
The next chapter introduces unidirectional datasets, which enable you to retrieve result sets
from an SQL connection.
dbExpress Datasets CHAPTER
2
IN THIS CHAPTER
• What Are dbExpress Datasets? 58
• Types of Datasets 59
• Data Manipulation 63
• BLOB Support 69
• Parameterized Queries 71
• Master/Detail Relationships 74
The employee rows are returned in a dataset. You saw in the preceding chapter that
TSQLConnection could directly process SQL statements that did not return a result set. For
queries that return a result set, TSQLDataSet (and its derivatives) should be used.
Let’s take a moment to discuss the characteristics of dbExpress datasets, and then we’ll move
into the concrete dbExpress classes that implement them.
2
FIGURE 2.1
DBEXPRESS
DATASETS
dbExpress datasets are read-only.
Types of Datasets
As with other Delphi database technologies (such as ADO and IBX), dbExpress supports three
different types of datasets: tables, queries, and stored procedures. These are discussed in the
following sections.
Tables
A table is a direct view of the underlying database table. It consists of all columns for all rows
in the table. You cannot limit the rows returned from the table, and you cannot select a subset
of columns (or join columns from another table).
Chapter 2
60
Queries
A query provides a way to retrieve a subset of the data stored in the underlying database table.
It also enables you to join information from one table to another. In general, a query enables
you to execute any SQL SELECT statement and return the results.
Stored Procedures
Stored procedures are procedures written in the underlying database, and stored in the database
itself. dbExpress stored procedures enable you to retrieve data from a database stored procedure.
General-Purpose Datasets
The dbExpress components that provide table-level, query-level, and stored procedure access
are TSQLTable, TSQLQuery, and TSQLStoredProc, respectively. These components are provided
solely for ease of conversion from BDE applications, and correspond to the BDE data-access
components TTable, TQuery, and TStoredProc.
For all new development, it is strongly recommended that you use the general-purpose component
TSQLDataSet. TSQLDataSet allows access to tables, queries, and stored procedures alike, and is
more flexible than any of the other special-purpose components mentioned previously.
TSQLDataSet implements almost all of the dbExpress dataset functionality. TSQLTable,
TSQLQuery, and TSQLStoredProc descend from TSQLDataSet and add behavior specific to
tables, queries, and stored procedures. In turn, TSQLDataSet descends from TDataSet, which is
the root class for all Delphi datasets.
Table 2.1 lists the relevant properties defined by TSQLDataSet.
Property Description
Active Set to True to open the dataset, or to False to close the dataset.
You can also read this property to determine whether the dataset is
currently open or closed.
CommandText The way in which CommandText is used depends on the value of
CommandType. See the following sections for more information on
CommandText.
CommandType Set CommandType to ctQuery to execute a query, to ctStoredProc to
execute a stored procedure, or to ctTable to open a table. See the
following sections for more information on CommandType.
DataSource Used to establish a master/detail link between two datasets. See the
section titled “Master/Detail Relationships” for more information.
dbExpress Datasets
61
MaxBlobSize Sets the maximum amount of data returned from BLOB fields. See
the section titled “BLOB Support” for more information.
ObjectView When True, the dataset provides additional support for ADT fields,
array fields, and master/detail relationships. See the section titled
“Master/Detail Relationships” for more information.
ParamCheck When True, the dataset automatically generates parameters whenever
CommandText changes. If you want to create parameters manually, 2
set this to False. See the section titled “Parameterized Queries” for
more information.
DBEXPRESS
DATASETS
Params Contains a list of input and output parameters for the current query
or stored procedure. See the section titled “Parameterized Queries”
for more information.
SortFieldNames Only used when ComandType = ctTable. Defines the order in which
data is returned from the server. See the section titled “Ordering
Data Returned from the Server” for more information.
SQLConnection The TSQLConnection component to which this dataset is connected.
You should set this property before setting any other properties in
the dataset.
Table-Level Access
To access an underlying database table, you can use the TSQLTable component. The fundamental
properties are SQLConnection and TableName. You can also set IndexName if you want to select
an index for record-ordering purposes. The following code snippet illustrates how to set table
properties and open a table-based dataset:
SQLTable1.SQLConnection := conn;
SQLTable1.TableName := ‘CONTACTS’;
SQLTable1.IndexName := ‘IX_CONNAME’;
SQLTable1.Open;
Query-Level Access
You can use TSQLQuery to create an ad hoc query for retrieving data from a database. The
following code shows how to do this:
SQLQuery1.SQLConnection := conn;
SQLQuery1.SQL.Text := ‘SELECT * FROM CONTACTS WHERE COUNTRY = “United
➥States”’;
SQLQuery1.Open;
Chapter 2
62
You can also create parameterized queries, as the following code snippet illustrates:
SQLQuery1.SQLConnection := conn;
SQLQuery1.SQL.Text := ‘SELECT * FROM CONTACTS WHERE COUNTRY = :TheCountry;
SQLQuery1.ParamByName(‘TheCountry’).Value := ‘United States’;
SQLQuery1.Open;
Parameterized queries are discussed in more detail later in the section titled “Parameterized
Queries.”
Again, if the underlying stored procedure accepts one or more parameters, you should use the
ParamByName method to set the parameters before executing the query.
As you can see, it’s almost identical to the code required for TSQLTable. There is one
additional line, to tell the dataset that it’s accessing a table, as opposed to a query or stored
procedure.
dbExpress Datasets
63
This code is also extremely similar to that used for a TSQLQuery component.
The following code snippet executes a stored procedure using the TSQLDataSet component: 2
SQLDataset1.SQLConnection := conn;
SQLDataset1.CommandType := ctStoredProc;
DBEXPRESS
DATASETS
SQLDataset1.CommandText := ‘ContactsByState’;
SQLDataset1.ParamByName(‘ASTATE’).Value := ‘FL’;
SQLDataset1.Open;
Data Manipulation
Now that we’ve discussed the components necessary for data access, let’s spend a few minutes
discussing the methods provided by those components. Because dbExpress datasets are a
unidirectional read-only technology, you’ll see that there isn’t a lot to cover in this area. The
following sections discuss the most common operations that you will perform on a dbExpress
dataset.
Opening a Dataset
After you’ve set the appropriate properties on the dataset, you need to open the dataset to
retrieve data from the database connection. There are actually two ways to do this, both of
which achieve exactly the same result:
SQLDataSet1.Open;
or
SQLDataSet1.Active := True;
Chapter 2
64
If you look at the VCL/CLX source code, you’ll see that TSQLDataSet.Open resolves to a call
to TDataSet.Open, which looks like this:
procedure TDataSet.Open;
begin
Active := True;
end;
It’s a matter of personal preference whether you make the method call to Open, or set the
Active property yourself. I find that I prefer the method call, but either way is correct.
Closing a Dataset
Closing a dataset is as easy as opening one:
SQLDataSet1.Close;
or
SQLDataSet1.Active := False;
Again, calling the Close method does nothing except set Active := False, so feel free to
adopt whichever method you prefer.
NOTE
Remember that closing the database connection also closes all open datasets. So, if
you have a number of open datasets (and you are finished with the database
connection), you can elect to simply close the connection rather than closing all open
datasets manually.
Usually, you know the name of the field that you want to access (as shown in the preceding
code snippet). However, if you’re writing a general-purpose database utility application that
works with any dbExpress-supported database or table, you might not know in advance what
columns are in the table that you are accessing. In those cases, you can access the Fields
object directly, like this:
ShowMessage(‘The first field’’s contents are: ‘ +
dbExpress Datasets
65
SQLDataset1.Fields[0].AsString);
You can also use the FieldCount property to determine the number of fields in the result set
and to loop through them, as the following code illustrates:
for Index := 0 to SQLDataset1.FieldCount - 1 do
// Do something with SQLDataset1.Fields[Index]
Navigating a Dataset
Because dbExpress datasets are unidirectional, there isn’t a lot of navigation that is supported.
The only two operations that you can perform are moving to the beginning of the dataset, and 2
moving to the next record in the result set. These operations are illustrated in the following
DBEXPRESS
code snippet:
DATASETS
while not SQLDataset1.EOF do begin
for Index := 0 to SQLDataset1.FieldCount - 1 do begin
// Do something with SQLDatasets.Fields[Index]
SQLDataset1.Next;
end;
end;
The following code listing is an example of all the concepts discussed so far in this chapter. It
illustrates how to use the TSQLTable, TSQLQuery, TSQLStoredProc, and TSQLDataSet components
to retrieve data from the ConMan database. The code listing also shows how to use these
components to loop through the results, and to do something with the data. (In this case, it
simply loads a TListView component with some of the field contents.)
Listing 2.1 shows the complete source code for the main form of the application.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, DBXpress, DB, SqlExpr, QStdCtrls, QComCtrls, QExtCtrls, FMTBcd,
QDBCtrls;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
btnConnect: TButton;
Chapter 2
66
conn: TSQLConnection;
SQLTable1: TSQLTable;
SQLQuery1: TSQLQuery;
SQLStoredProc1: TSQLStoredProc;
SQLDataSet1: TSQLDataSet;
Label1: TLabel;
lvResults: TListView;
procedure btnConnectClick(Sender: TObject);
private
{ Private declarations }
procedure OpenTable;
procedure OpenQuery;
procedure OpenStoredProcedure;
procedure OpenDataset;
procedure LoadResults(DataSet: TDataSet);
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
uses DatasetTypeForm;
{$R *.xfm}
procedure TfrmMain.OpenTable;
begin
SQLTable1.TableName := ‘CONTACTS’; 2
SQLTable1.IndexName := ‘IX_CONNAME’;
SQLTable1.Open;
DBEXPRESS
DATASETS
LoadResults(SQLTable1);
end;
procedure TfrmMain.OpenQuery;
begin
SQLQuery1.SQL.Text := ‘SELECT * FROM CONTACTS ‘ +
‘WHERE COUNTRY = “United States”’;
SQLQuery1.Open;
LoadResults(SQLQuery1);
end;
procedure TfrmMain.OpenStoredProcedure;
begin
SQLStoredProc1.StoredProcName := ‘CONTACTSBYSTATE’;
SQLStoredProc1.ParamByName(‘ASTATE’).Value := ‘FL’;
SQLStoredProc1.Open;
LoadResults(SQLStoredProc1);
end;
procedure TfrmMain.OpenDataset;
begin
SQLDataset1.CommandType := ctQuery;
SQLDataset1.CommandText := ‘SELECT FIRST, LAST, PHONE FROM CONTACTS ‘ +
‘ORDER BY LAST, FIRST’;
SQLDataset1.Open;
LoadResults(SQLDataset1);
end;
Chapter 2
68
end.
As you can see from the code, when the user clicks the Connect button, the program creates an
instance of TfrmDatasetType (shown in Listing 2.2), which asks the user to select the type of
dataset to open: table, query, stored procedure, or generic dataset. After the user selects the
dataset type, the program calls one of four methods: OpenTable, OpenQuery,
OpenStoredProcedure, or OpenDataset. These methods each set the properties of the selected
dataset and make a call to LoadResults.
Notice that LoadResults takes a TDataSet as a parameter, which I indicated earlier is the root
class of all datasets. What this means is that LoadResults would actually work with any
dataset, whether it is a dbExpress dataset, BDE dataset, or some third-party dataset.
LoadResults loops through the records in the dataset, loading the contact’s first name, last
name, and phone number into a TListView.
interface
uses
dbExpress Datasets
69
type
TDatasetType = (dtTable, dtQuery, dtStoredProc, dtDataset);
TfrmDatasetType = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
btnOk: TButton; 2
btnCancel: TButton;
grpDatasetType: TRadioGroup;
DBEXPRESS
DATASETS
procedure btnOkClick(Sender: TObject);
private
{ Private declarations }
FDatasetType: TDatasetType;
public
{ Public declarations }
property DatasetType: TDatasetType read FDatasetType;
end;
implementation
{$R *.xfm}
end.
BLOB Support
Like most datasets, dbExpress datasets support BLOB data. BLOB stands for Binary Large
Object, and is used to store free-format data (such as images, memos, and the like).
BLOBs can easily become large, so many times you will want to limit the size of the BLOB
data that is retrieved from the database to improve query performance. For example, say that
you have a BLOB field that’s used to store an image of a contact. Executing a query that
returns a large result set (such as SELECT * FROM CONTACTS) could potentially retrieve
Chapter 2
70
thousands of records from the database. If each contact has a picture that averages 100K in
size, the amount of data returned from the server will be huge. This problem compounds when
the database connection is across a local area network, or worse, across the Internet.
FIGURE 2.2
TSQLDataSet is used to retrieve data from the database.
In these cases, you might want to eliminate the BLOB column from the result set. There are
two ways that you can accomplish this. First, if you’re executing a predefined query, simply
omit the BLOB field from the query. For example, if you are executing the query SELECT
FIRST, LAST, PHONE, IMAGE FROM CONTACTS, modify the SQL statement to be SELECT
FIRST, LAST, PHONE FROM CONTACTS.
Second, although the previous solution works fine if you know the exact columns that you are
retrieving from the database, what about a general-purpose database utility? If you don’t know
the column names or field types, you can execute a statement like the following:
SELECT * FROM CONTACTS
In a situation such as this, you can set TSQLDataSet’s BlobSize property to determine the
maximum amount of data to retrieve for each BLOB field. If this parameter is 0 (the default),
the maximum BLOB size is determined by the associated TSQLConnection’s BlobSize parameter.
If this parameter is –1, the dataset retrieves the entire BLOB, regardless of size. Any other
value constitutes the maximum number of bytes to retrieve for a BLOB.
NOTE
Regardless of whether you set BlobSize at the connection level or at the dataset level,
it applies to all BLOB fields returned in the dataset. There is no way to retrieve just the
first 100 bytes of one BLOB field and the entire contents of another BLOB field.
dbExpress Datasets
71
Parameterized Queries
In most of the examples shown so far in this chapter, when querying the database, the entire
query was specified. For example,
SELECT * FROM CONTACTS WHERE COUNTRY = “United States”
Although this works, it is less than efficient if you are going to execute the same general query
multiple times. For example, say that you want to first retrieve all the contacts in the United
States, and then all the contacts in Canada, and finally all the contacts in Mexico. You could
write something like the following: 2
SQLDataSet1.CommandType := ctQuery;
DBEXPRESS
SQLDataSet1.CommandText := ‘SELECT * FROM CONTACTS ‘ +
DATASETS
‘WHERE COUNTRY = “United States”’;
SQLDataSet1.Open;
ProcessDataSet;
SQLDataSet1.Close;
In this example, ProcessDataSet is some fictitious method that would operate on the results of
the query in some manner.
Although the preceding code works correctly, it is far from optimal. Each time
SQLDataSet1.CommandText is set, the backend database engine must parse and prepare the
SQL statement. A better way to accomplish the same result is to parameterize the query, like
this:
SQLDataSet1.CommandText := ‘SELECT * FROM CONTACTS WHERE COUNTRY = :Country’;
This sets up a parameter named Country which acts like a placeholder in the SQL statement.
By setting various values for this parameter, you can issue the same SQL statement for different
countries, like this:
SQLDataSet1.CommandType := ctQuery;
SQLDataSet1.CommandText := ‘SELECT * FROM CONTACTS WHERE COUNTRY = :Country’;
SQLDataSet1.ParamByName(‘Country’).AsString := ‘United States’;
SQLDataSet1.Open;
ProcessDataSet;
Chapter 2
72
SQLDataSet1.Close;
SQLDataSet1.ParamByName(‘Country’).AsString := ‘Canada’;
SQLDataSet1.Open;
ProcessDataSet;
SQLDataSet1.Close;
SQLDataSet1.ParamByName(‘Country’).AsString := ‘Mexico’;
SQLDataSet1.Open;
ProcessDataSet;
In this scenario, the SQL statement is prepared only once—the first time that it is executed.
After that, the statement does not need to be prepared again because the only thing that
changes is the Country parameter.
NOTE
Note that the name of the parameter does not need to be the same as the column
name that it refers to. In the previous example, the parameter could have been
named ACountry, CountryParam, or Fred.
There is one additional property that governs how parameters are treated: the ParamCheck
property. When ParamCheck is set to True, parameters are automatically created by dbExpress
(as the previous example indicates). However, if you set ParamCheck to False, you are
responsible for creating the parameters yourself. The following code snippet shows how
this is done:
SQLDataSet1.CommandType := ctQuery;
SQLDataSet1.ParamCheck := False;
SQLDataSet1.CommandText := ‘SELECT * FROM CONTACTS WHERE COUNTRY = :Country’;
SQLDataSet1.Params.CreateParam(‘Country’, ftString, ptInput);
SQLDataSet1.ParamByName(‘Country’).AsString := ‘United States’;
SQLDataSet1.Open;
Note that, typically, you would only set ParamCheck to False when issuing a DDL statement
that creates a stored procedure which accepts parameters as part of the stored procedure.
Because that sounds confusing, let’s take a look at an example:
SQLDataSet1.CommandType := ctQuery;
SQLDataSet1.ParamCheck := False;
SQLDataSet1.CommandText :=
‘CREATE PROCEDURE CONTACTSBYTITLE(ATITLE VARCHAR(20)) ‘ +
‘RETURNS ( ‘ +
‘ID INTEGER, ‘ +
‘FIRST VARCHAR(20), ‘ +[DM] Fix typo: should be FIRST
dbExpress Datasets
73
‘LAST VARCHAR(30), ‘ +
‘) ‘ +
‘AS ‘ +
‘BEGIN +
‘ FOR SELECT ID, FIRST, LAST ‘ +
‘ FROM CONTACTS ‘ +
‘ WHERE TITLE = :ATITLE ‘ +
‘ INTO :ID, :FIRST, :LAST DO ‘ +
‘ BEGIN ‘ +
‘ SUSPEND; ‘ +
‘ END ‘ +
‘END;’;
2
SQLDataSet1.ExecSQL;
DBEXPRESS
DATASETS
In this case, the parameters ATITLE, ID, FIRST, and LAST used in the body of the stored procedure
are not parameters to the SQL statement at all. They are a part of the stored procedure. To
keep dbExpress from treating them as parameters, you should set ParamCheck to False before
setting CommandText.
If you don’t know the name of the underlying index, you can set the IndexFieldNames property
instead. IndexFieldNames is a semicolon-delimited list of fields that make up the index.
SQLTable1.IndexFieldNames := ‘LAST;FIRST’;
NOTE
You do not need to ensure that there is an index defined on the fields that you set in
the IndexFieldNames property. Behind the scenes, TSQLTable creates an ORDER BY
clause for the SQL statement that it passes to the database (based on the fields listed
in IndexFieldNames).
Chapter 2
74
If there is an index defined in the database that supports the ORDER BY clause, the underlying
database engine uses the index for increased speed. If not, the database engine sorts the data in
the requested order before it is returned to the application.
Master/Detail Relationships
Although standalone datasets are useful, datasets are commonly related to other datasets in
master/detail relationships. A master/detail relationship (also known as a one-to-many relationship)
is one in which a single record in one dataset corresponds to one, or more, records in another
dataset.
The most commonly cited example of a master/detail relationship uses customers, orders, and
items as its datasets. A single customer can place more than one order with a given vendor. In
turn, a single order can contain more than one item. Figure 2.3 shows a graphic representation
of typical customer, order, and item datasets.
Customers
ID
More fields… Orders
ID
CustomerID Items
More fields… ID
OrderID
More fields…
FIGURE 2.3
Master/Detail relationships can have multiple levels.
In the ConMan database, the CONTACT and ACTIVITIES tables are joined in a master/detail
relationship on the ContactID field.
To create this master/detail link in an application, you would perform the following steps:
dbExpress Datasets
75
4. Drop another TSQLDataSet on the form. Set the Name property to sqlActivities and the
DataSource property to dsContacts. This is the detail dataset. 2
5. Set sqlActivities.CommandText to SELECT * FROM ACTIVITIES WHERE ContactID =
DBEXPRESS
:ContactID.
DATASETS
That’s all that’s required to establish a master/detail relationship in your program code. You
should note the following points:
• The detail dataset’s CommandText property is always a parameterized query. The parameter
names actually refer to column names in the master dataset.
• Whenever the master dataset changes records, the detail dataset automatically retrieves
the records that are associated with the current master record.
The first point deserves a little more explanation.
You’ll recall from the section titled “Parameterized Queries” that when using parameterized
queries, you typically make calls to TSQLDataSet.ParamByName to set the parameters.
With a detail dataset, parameter substitution is a bit more automated. VCL/CLX notices that
the detail dataset’s DataSource parameter is assigned, so it looks to the data source’s dataset as
the source of parameter values.
For example, in the CONTACT/ACTIVITIES example, sqlActivities.CommandText contains
a single parameter: ContactID. When Delphi assigns the value of the ContactID parameter, it
looks to the master dataset for a column named ContactID. Because the master dataset does,
indeed, contain a column named ContactID, the value of the parameter is taken from that column.
NOTE
You can create a detail dataset that gets only some of its parameter values from the
master dataset. In this case, you must set the values of the other parameters manually.
For example, given the SQL statement SELECT * FROM ACTIVITIES WHERE (ContactID
= :ContactID) AND (SCHEDULED > :Earliest), the detail dataset would get the
value for the ContactID parameter from the master dataset. You would make a call
to sqlActivities.ParamByName to set the Earliest parameter.
Chapter 2
76
As you scroll through the master dataset, the detail dataset automatically updates itself to stay
in sync with the master.
Listing 2.3 shows the complete source code for an application that makes use of parameterized
queries, master/detail relationships, and BLOB fields.
interface
uses
Types, SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, DBXpress, DB, SqlExpr, QStdCtrls, QComCtrls, QExtCtrls, FMTBcd,
QDBCtrls;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
btnConnect: TButton;
btnDisconnect: TButton;
Label1: TLabel;
DBImage1: TDBImage;
DBText1: TDBText;
Label2: TLabel;
Label3: TLabel;
Label4: TLabel;
Label5: TLabel;
Label6: TLabel;
Label7: TLabel;
Label8: TLabel;
Label9: TLabel;
DBText2: TDBText;
DBText3: TDBText;
DBText4: TDBText;
DBText5: TDBText;
DBText6: TDBText;
DBText7: TDBText;
DBText8: TDBText;
DBText9: TDBText;
DBNavigator1: TDBNavigator;
Bevel1: TBevel;
Label10: TLabel;
lvActivities: TListView;
dbExpress Datasets
77
Label11: TLabel;
cbCountry: TComboBox;
btnRetrieve: TButton;
conn: TSQLConnection;
sqlContacts: TSQLDataSet;
sqlContactsFIRST: TStringField;
sqlContactsLAST: TStringField;
sqlContactsDEAR: TStringField;
sqlContactsTITLE: TStringField;
sqlContactsCOMPANYNAME: TStringField;
2
sqlContactsADDRESS1: TStringField;
DBEXPRESS
sqlContactsADDRESS2: TStringField;
DATASETS
sqlContactsCITY: TStringField;
sqlContactsSTATE: TStringField;
sqlContactsPOSTALCODE: TStringField;
sqlContactsCOUNTRY: TStringField;
sqlContactsPHONE: TStringField;
sqlContactsFAX: TStringField;
sqlContactsCELLULAR: TStringField;
sqlContactsPAGER: TStringField;
sqlContactsEMAIL: TStringField;
sqlContactsIMAGE: TBlobField;
sqlContactsNOTES: TMemoField;
DataSource1: TDataSource;
sqlActivities: TSQLDataSet;
sqlActivitiesCONTACTID: TIntegerField;
sqlActivitiesDESCRIPTION: TStringField;
sqlActivitiesSCHEDULED: TSQLTimeStampField;
sqlActivitiesCOMPLETED: TSQLTimeStampField;
sqlContactsCONTACTID: TIntegerField;
sqlActivitiesTODOID: TIntegerField;
procedure btnConnectClick(Sender: TObject);
procedure btnNextClick(Sender: TObject);
procedure btnDisconnectClick(Sender: TObject);
procedure btnRetrieveClick(Sender: TObject);
procedure connAfterDisconnect(Sender: TObject);
procedure connAfterConnect(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
procedure sqlContactsAfterScroll(DataSet: TDataSet);
private
{ Private declarations }
public
{ Public declarations }
end;
Chapter 2
78
implementation
{$R *.xfm}
begin
sqlContacts.Next;
end;
DBEXPRESS
DATASETS
while not sqlActivities.EOF do begin
ListItem := lvActivities.Items.Add;
ListItem.Caption := DateTimeToStr(sqlActivitiesSCHEDULED.AsDateTime);
ListItem.SubItems.Add(sqlActivitiesDESCRIPTION.AsString);
if not sqlActivitiesCOMPLETED.IsNull then
ListItem.Data := Pointer(1);
sqlActivities.Next;
end;
finally
lvActivities.Items.EndUpdate;
end;
end;
end.
FIGURE 2.4
Advanced displays contact data and related activities.
SchemaType refers to the type of schema data to return, and must be one of the values listed in
Table 2.2.
SchemaObjectName specifies the name of the table or stored procedure to return data for. It is
only used for schema types of stColumns, stProcedureParams, and stIndexes. If the schema
type is stColumns or stIndexes, SchemaObjectName specifies the table to return column or
index information for. If the schema type is stProcedureParams, SchemaObjectName specifies
the name of the stored procedure to return parameter information for.
dbExpress Datasets
81
SchemaPattern is an SQL pattern that is used to filter the data that’s returned in the result set.
For instance, to return only columns that start with the letter A, you could pass a
SchemaPattern of A% to the call to SetSchemaInfo. If you don’t want to filter the result set, set
SchemaPattern to an empty string.
Within SchemaPattern, use a percent sign (%) to match a string of any length and an under-
score (_) to match a single character. If you want to include a percent sign or underscore in the
pattern, double it up (%% or __).
After you’ve retrieved schema data for a database, you can use the same dataset to run normal
queries against the database by calling SetSchemaInfo with a SchemaType of stNoSchema. 2
Listing 2.4 shows the complete source code for an example program that can extract and dis-
DBEXPRESS
DATASETS
play schema information for a dbExpress database. After the listing, I’ll give just a quick
overview of the code because most of it should be self-explanatory at this point.
Figure 2.5 shows the Schema program as it displays column information from the CONTACTS
table in the ConMan.gdb database.
FIGURE 2.5
The Schema application decodes and displays column information.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, DBXpress, FMTBcd, DB, SqlExpr, QExtCtrls, QStdCtrls, QComCtrls;
Chapter 2
82
type
TfrmMain = class(TForm)
pnlClient: TPanel;
conn: TSQLConnection;
dataset: TSQLDataSet;
Label1: TLabel;
lvColumns: TListView;
grpSchemaType: TRadioGroup;
Label2: TLabel;
ecObjectName: TEdit;
btnRetrieve: TButton;
ecSchemaPattern: TEdit;
Label3: TLabel;
procedure btnRetrieveClick(Sender: TObject);
private
{ Private declarations }
function GetTableTypeString(TableType: Integer): string;
function GetProcTypeString(ProcType: Integer): string;
function GetColTypeString(ColType: Integer): string;
function GetColDataTypeString(ColDataType: Integer): string;
function GetColSubTypeString(ColSubType: Integer): string;
function GetIndexTypeString(IndexType: Integer): string;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
// Columns, Indexes, and Procedure Params must have a schema object name
case SchemaType of
dbExpress Datasets
83
stColumns,
stIndexes,
stProcedureParams:
if ecObjectName.Text = ‘’ then
raise Exception.Create(‘You must enter a schema object name ‘ +
‘for this schema type.’);
end;
conn.Open;
try
2
dataset.SetSchemaInfo(SchemaType, ecObjectName.Text, ecSchemaPattern.Text);
DBEXPRESS
dataset.Open;
DATASETS
lvColumns.Items.BeginUpdate;
try
lvColumns.Items.Clear;
lvColumns.Columns.BeginUpdate;
try
lvColumns.Columns.Clear;
for Index := 0 to dataset.FieldCount - 1 do begin
ListColumn := lvColumns.Columns.Add;
ListColumn.Caption := dataset.Fields[Index].FieldName;
end;
ListItem.Caption := dataset.Fields[0].AsString;
for Index := 1 to dataset.FieldCount - 1 do begin
if dataset.Fields[Index].FieldName = ‘TABLE_TYPE’ then
ListItem.SubItems.Add(GetTableTypeString(
dataset.Fields[Index].AsInteger))
else if dataset.Fields[Index].FieldName = ‘PROC_TYPE’ then
ListItem.SubItems.Add(GetProcTypeString(
dataset.Fields[Index].AsInteger))
else if dataset.Fields[Index].FieldName = ‘COLUMN_TYPE’ then
ListItem.SubItems.Add(GetColTypeString(
dataset.Fields[Index].AsInteger))
else if dataset.Fields[Index].FieldName = ‘COLUMN_DATATYPE’ then
ListItem.SubItems.Add(GetColDataTypeString(
dataset.Fields[Index].AsInteger))
else if dataset.Fields[Index].FieldName = ‘COLUMN_SUBTYPE’ then
ListItem.SubItems.Add(GetColSubTypeString(
dataset.Fields[Index].AsInteger))
Chapter 2
84
dataset.Next;
end;
finally
lvColumns.Columns.EndUpdate;
end;
finally
lvColumns.Items.EndUpdate;
end;
finally
conn.Close;
end;
end;
begin
Result := ‘’;
Check(eSQLTable, ‘Table’);
Check(eSQLView, ‘View’);
Check(eSQLSynonym, ‘Synonym’);
Check(eSQLSystemTable, ‘System’);
Check(eSQLTempTable, ‘Temp’);
Check(eSQLLocal, ‘Local’);
if Result = ‘’ then
Result := ‘$’ + IntToHex(TableType, 2);
end;
dbExpress Datasets
85
DBEXPRESS
begin
DATASETS
Result := ‘’;
Check(eSQLProcedure, ‘Procedure’);
Check(eSQLFunction, ‘Function’);
Check(eSQLPackage, ‘Package’);
Check(eSQLSysProcedure, ‘System’);
if Result = ‘’ then
Result := ‘$’ + IntToHex(ProcType, 2);
end;
begin
Result := ‘’;
if Result = ‘’ then
Result := ‘$’ + IntToHex(ColType, 2);
Chapter 2
86
end;
DBEXPRESS
fldstADTDATE: Result := ‘ADT Date’;
DATASETS
else Result := ‘$’ + IntToHex(ColSubType, 2);
end;
end;
begin
Result := ‘’;
Check(eSQLNonUnique, ‘Non-unique’);
Check(eSQLUnique, ‘Unique’);
Check(eSQLPrimaryKey, ‘Primary’);
if Result = ‘’ then
Result := ‘$’ + IntToHex(IndexType, 2);
end;
end.
The guts of the Schema application are contained within a single method: btnRetrieveClick.
btnRetrieveClick determines what schema type the user selected and ensures that the user
enters an object name if the requested schema type is columns, indexes, or procedure parameters.
Chapter 2
88
When the schema type is known, it is a simple matter to make the appropriate call to
SetSchemaInfo, open the dataset, and load the list view with the schema information. Certain
columns (namely TABLE_TYPE, PROC_TYPE, COLUMN_TYPE, COLUMN_DATATYPE, COLUMN_SUBTYPE,
and INDEX_TYPE) are bitmapped numeric columns. The program makes calls to a handful of
helper routines to display a textual representation of these values. The rest of the columns are
displayed as is.
You will notice when you run this application that different schema types return different data
fields. Tables 2.3–2.7 explain the columns that are returned for each of the schema types.
Table 2.3 lists the columns that are returned for a schema type of stTables or
stSystemTables.
Column Description
RECNO The absolute record number. It is one for the first record, two for the
second, and so on.
CATALOG_NAME The name of the catalog, or database, that contains the table.
SCHEMA_NAME The owner of the table.
TABLE_NAME The table name.
TABLE_TYPE A bitmapped value that represents the type of table. See Listing 2.4, or
the source code for DBXpress.pas, for an explanation of the
possible values for this field.
Table 2.4 lists the columns that are returned for a schema type of stProcedures.
Column Description
RECNO The absolute record number. It is one for the first record, two for the
second, and so on.
CATALOG_NAME The name of the catalog, or database, that contains the stored procedure.
SCHEMA_NAME The owner of the stored procedure.
PROC_NAME The name of the stored procedure.
PROC_TYPE A bitmapped value that represents the type of stored procedure. See
Listing 2.4, or the source code for DBXpress.pas, for an explanation of
the possible values for this field.
IN_PARAMS The number of input parameters to the stored procedure.
OUT_PARAMS The number of output parameters from the stored procedure.
dbExpress Datasets
89
Table 2.5 lists the columns that are returned for a schema type of stColumns.
Column Description
RECNO The absolute record number. It is one for the first record, two for
the second, and so on.
CATALOG_NAME The name of the catalog, or database, that contains the table.
SCHEMA_NAME The owner of the column.
TABLE_NAME The name of the table containing the column. 2
COLUMN_NAME The name of the column.
DBEXPRESS
DATASETS
COLUMN_POSITION The zero-based position of the column in the table definition.
COLUMN_TYPE A bitmapped value that represents the type of column. See Listing
2.4, or the source code for DBXpress.pas, for an explanation of the
possible values for this field.
COLUMN_DATATYPE The logical field type. See Listing 2.4, or the source code for
DBXpress.pas, for an explanation of the possible values for this field.
COLUMN_TYPENAME The SQL column type (VARCHAR, BLOB, and the like).
COLUMN_SUBTYPE The logical field subtype. See Listing 2.4, or the source code for
DBXpress.pas, for an explanation of the possible values for this field.
COLUMN_LENGTH The size of the column in bytes.
COLUMN_PRECISION The precision of the column. It varies by column type. For example,
it is the number of characters for strings, and it is the number of
significant digits for BCD values.
COLUMN_SCALE The numeric scale. It is the number of digits to the right of the
decimal point for BCD columns.
COLUMN_NULLABLE It is one if the column can contain NULL values, and zero if it cannot
contain NULL values.
Table 2.6 lists the columns that are returned for a schema type of stProcedureParams.
Column Description
RECNO The absolute record number. It is one for the first record, two for the
second, and so on.
CATALOG_NAME The name of the catalog, or database, that contains the stored
procedure.
Chapter 2
90
Table 2.7 lists the columns that are returned for a schema type of stIndexes.
Column Description
RECNO The absolute record number. It is one for the first record, two for
the second, and so on.
CATALOG_NAME The name of the catalog, or database, that contains the index.
SCHEMA_NAME The owner of the index.
TABLE_NAME The name of the table on which the index is defined.
INDEX_NAME The name of the index.
COLUMN_NAME The name of the column that is part of the index.
dbExpress Datasets
91
DBEXPRESS
DATASETS
is only supported by certain databases, such as Oracle.
Summary
This chapter discussed the dbExpress dataset component TSQLDataSet, including the following
key concepts:
• TSQLDataSet is a unidirectional, read-only, lightweight data access mechanism. It can be
used to retrieve data from a table, query, or stored procedure in the database.
• dbExpress datasets support two navigation options: moving to the beginning of the
dataset and moving to the next record in the result set.
• To retrieve data from a TSQLDataSet, you typically call the FieldByName method.
• TSQLDataSet supports BLOB information. To improve query performance, you can set
TSQLDataSet’s BlobSize property to specify the maximum amount of data to retrieve for
each BLOB field.
• To make repeated queries more efficient, you can parameterize them by creating a
placeholder in the SQL statement. Then, you can set different values for this parameter.
The SQL statement is prepared only once—the first time that it is executed.
• To order the data that’s returned from the database, you can specify an ORDER BY
clause in the dataset’s CommandText property.
• You can easily set up master/detail relationships between tables and queries.
• It is possible to use dbExpress datasets to retrieve detailed schema information for tables,
queries, and stored procedures in a database.
The following chapter begins a two-chapter exploration of client datasets.
Client Dataset Basics CHAPTER
3
IN THIS CHAPTER
• What Is a Client Dataset? 94
• Searching 136
Chapter 3
94
NOTE
Client datasets were originally introduced in Delphi 3, and they presented a method
for creating multitier applications in Delphi. As their use became more widespread,
they were enhanced to support additional single-tier functionality.
The base class in VCL/CLX for client datasets is TCustomClientDataSet. Typically, you don’t
work with TCustomClientDataSet directly, but with its direct descendent, TClientDataSet.
(In Chapter 7, “Dataset Providers,” I’ll introduce you to other descendents of
TCustomClientDataSet.) For readability and generalization, I’ll refer to client datasets
generically in this book as TClientDataSet.
• On-the-fly indexing. Client datasets enable you to create and use indexes on-the-fly,
making them extremely versatile.
• Automatic undo support. Client datasets provide multilevel undo support, making it easy
to perform what if operations on your data. Undo support is discussed in Chapter 4,
“Advanced Client Dataset Operations.”
• Maintained aggregates. Client datasets can automatically calculate averages, subtotals,
and totals over a group of records. Maintained aggregates are discussed in detail in
Chapter 4.
The perceived disadvantages include
• Memory based. This client dataset advantage can also be a disadvantage. Because client
datasets reside in RAM, their size is limited by the amount of available RAM.
• Single user. Client datasets are inherently single-user datasets because they are kept in
RAM.
When you understand client datasets, you’ll discover that these so-called disadvantages really
aren’t detrimental to your application at all. In particular, basing client datasets entirely in
RAM has both advantages and disadvantages. 3
CLIENT DATASET
Because they are kept entirely in your computer’s RAM, client datasets are extremely useful
for temporary tables, small lookup tables, and other nonpersistent database needs. Client
BASICS
datasets also are fast because they are RAM based. Inserting, deleting, searching, sorting, and
traversing in client datasets are lightening fast.
On the flip side, you need to take steps to ensure that client datasets don’t grow too large
because you waste precious RAM if you attempt to store huge databases in in-memory
datasets. Fortunately, client datasets store their data in a very compact form. (I’ll discuss this
in more detail in the “Undo Support” section of Chapter 7.)
Because they are memory based, client datasets are inherently single user. Remote machines
do not have access to a client dataset on a local machine. In Chapter 8, “DataSnap,” you’ll
learn how to connect a client dataset to an application server in a three-tier configuration that
supports true multiuser operation.
FIGURE 3.1
Use the New Field dialog to add a field to a dataset.
If you’re familiar with the field editor, you notice a new field type available for client datasets,
called Aggregate fields. I’ll discuss Aggregate fields in detail in the following chapter. For now,
you should understand that you can add data, lookup, calculated, and internally calculated
fields to a client dataset—just as you can for any dataset.
The difference between client datasets and other datasets is that when you create a data field
for a typical dataset, all you are doing is creating a persistent field object that maps to a field in
the underlying database. For a client dataset, you are physically creating the field in the dataset
along with a persistent field object. At design-time, there is no way to create a field in a client
dataset without also creating a persistent field object.
Data Fields
Most of the fields in your client datasets will be data fields. A data field represents a field that
is physically part of the dataset, as opposed to a calculated or lookup field (which are discussed
in the following sections). You can think of calculated and lookup fields as virtual fields
because they appear to exist in the dataset, but their data actually comes from another location.
Client Dataset Basics
97
Let’s add a field named ID to our dataset. In the field editor, enter ID in the Name edit control.
Tab to the Type combo box and type Integer, or select it from the drop-down list. (The
component name has been created for you automatically.) The Size edit control is disabled
because Integer values are a fixed-length field. The Field type is preset to Data, which is
what we want. Figure 3.2 shows the completed dialog.
FIGURE 3.2
The New Field dialog after entering information for a new field.
Click OK to add the field to the client dataset. You’ll see the new ID field listed in the field editor. 3
CLIENT DATASET
Now add a second field, called LastName. Right-click the field editor to display the New Field
BASICS
dialog and enter LastName in the Name edit control. In the Type combo, select String. Then, set
Size to 30—the size represents the maximum number of characters allowed for the field. Click
OK to add the LastName field to the dataset.
Similarly, add a 20-character FirstName field and an Integer Department field.Finally, let’s
add a Salary field. Open the New Field dialog. In the Name edit control, type Salary. Set the
Type to Currency and click OK. (The currency type instructs Delphi to automatically display
it with a dollar sign.)
If you have performed these steps correctly, the field editor looks like Figure 3.3.
FIGURE 3.3
The field editor after adding five fields.
Chapter 3
98
That’s enough fields for this dataset. In the next section, I’ll show you how to create a
calculated field.
Calculated Fields
Calculated fields, as indicated previously, don’t take up any physical space in the dataset.
Instead, they are calculated on-the-fly from other data stored in the dataset. For example, you
might create a calculated field that adds the values of two data fields together. In this section,
we’ll create two calculated fields: one standard and one internal.
NOTE
Actually, internal calculated fields do take up space in the dataset, just like a standard
data field. For that reason, you can create indexes on them like you would on a data
field. Indexes are discussed later in this chapter.
from one data-aware control to another and the current record has been modified. If
AutoCalcFields is False, calculated fields are computed when the dataset is opened, when the
dataset enters edit mode, and when a record is retrieved from an underlying database into the
dataset.
There are two reasons that you might want to use an internal calculated field instead of a standard
calculated field. If you want to index the dataset on a calculated field, you must use an internal
calculated field. (Indexes are discussed in detail later in this chapter.) Also, you might elect to
use an internal calculated field if the field value takes a relatively long time to calculate.
Because they are calculated once and stored in RAM, internal calculated fields do not have to
be computed as often as standard calculated fields.
Let’s add an internal calculated field to our dataset. The field will be called Name, and it will
concatenate the FirstName and LastName fields together. We probably will want an index on
this field later, so we need to make it an internal calculated field.
Open the New Field dialog, and enter a Name of Name and a Type of String. Set Size to 52
(which accounts for the maximum length of the last name, plus the maximum length of the
first name, plus a comma and a space to separate the two).
3
In the Field Type radio group, select InternalCalc and click OK.
CLIENT DATASET
Providing Values for Calculated Fields
BASICS
At this point, we’ve created our calculated fields. Now we need to provide the code to
calculate the values. TClientDataSet, like all Delphi datasets, supports a method named
OnCalcFields that we need to provide a body for.
Click the client dataset again, and in the Object Inspector, click the Events tab. Double-click
the OnCalcFields event to create an event handler.
We’ll calculate the value of the Bonus field first. Flesh out the event handler so that it looks
like this:
procedure TForm1.cdsEmployeeCalcFields(DataSet: TDataSet);
begin
cdsEmployeeBonus.AsFloat := cdsEmployeeSalary.AsFloat * 0.05;
end;
That’s easy—we just take the value of the Salary field, multiply it by five percent (0.05), and
store the value in the Bonus field.
Now, let’s add the Name field calculation. A first (reasonable) attempt looks like this:
procedure TForm1.cdsEmployeeCalcFields(DataSet: TDataSet);
begin
cdsEmployeeBonus.AsFloat := cdsEmployeeSalary.AsFloat * 0.05;
Chapter 3
100
cdsEmployeeName.AsString := cdsEmployeeLastName.AsString + ‘, ‘ +
cdsEmployeeFirstName.AsString;
end;
This works, but it isn’t efficient. The Name field calculates every time the Bonus field calculates.
However, recall that it isn’t necessary to compute internal calculated fields as often as standard
calculated fields. Fortunately, we can check the dataset’s State property to determine whether
we need to compute internal calculated fields or not, like this:
procedure TForm1.cdsEmployeeCalcFields(DataSet: TDataSet);
begin
cdsEmployeeBonus.AsFloat := cdsEmployeeSalary.AsFloat * 0.05;
Notice that the Bonus field is calculated every time, but the Name field is only calculated when
Delphi tells us that it’s time to compute internal calculated fields.
Lookup Fields
Lookup fields are similar, in concept, to calculated fields because they aren’t physically stored
in the dataset. However, instead of requiring you to calculate the value of a lookup field,
Delphi gets the value from another dataset. Let’s look at an example.
Earlier, we created a Department field in our dataset. Let’s create a new Department dataset to
hold department information.
Drop a new TClientDataSet component on your form and name it cdsDepartment. Add two
fields: Dept (an integer) and Description (a 30-character string).
Show the field editor for the cdsEmployee dataset by double-clicking the dataset. Open the
New Field dialog. Name the field DepartmentName, and give it a Type of String and a Size of
30.
In the FieldType radio group, select Lookup. Notice that two of the fields in the Lookup
definition group box are now enabled. In the Key Fields combo, select Department. In the
Dataset combo, select cdsDepartment.
At this point, the other two fields in the Lookup definition group box are accessible. In the
Lookup Keys combo box, select Dept. In the Result Field combo, select Description. The
completed dialog should look like the one shown in Figure 3.4.
Client Dataset Basics
101
FIGURE 3.4
Adding a lookup field to a dataset.
The important thing to remember about lookup fields is that the Key field represents the field
in the base dataset that references the lookup dataset. Dataset refers to the lookup dataset. The
Lookup Keys combo box represents the Key field in the lookup dataset. The Result field is
the field in the lookup dataset from which the lookup field obtains its value.
To create the dataset at design time, you can right-click the TClientDataSet component and
select Create DataSet from the pop-up menu. 3
Now that you’ve seen how to create a client dataset at design-time, let’s see what’s required to
CLIENT DATASET
create a client dataset at runtime.
BASICS
Creating a Client Dataset at Runtime
To create a client dataset at runtime, you start with the following skeletal code:
var
CDS: TClientDataSet;
begin
CDS := TClientDataSet.Create(nil);
try
// Do something with the client dataset here
finally
CDS.Free;
end;
end;
After you create the client dataset, you typically add fields, but you can load the client dataset
from a disk instead (as you’ll see later in this chapter in the section titled “Persisting Client
Datasets”).
Chapter 3
102
AddFieldDef
TFieldDefs.AddFieldDef is defined like this:
function AddFieldDef: TFieldDef;
As you can see, AddFieldDef takes no parameters and returns a TFieldDef object. When you
have the TFieldDef object, you can set its properties, as the following code snippet shows.
var
FieldDef: TFieldDef;
begin
FieldDef := ClientDataSet1.FieldDefs.AddFieldDef;
FieldDef.Name := ‘Name’;
FieldDef.DataType := ftString;
FieldDef.Size := 20;
FieldDef.Required := True;
end;
Add
A quicker way to add fields to a client dataset is to use the TFieldDefs.Add method, which is
defined like this:
procedure Add(const Name: string; DataType: TFieldType; Size: Integer = 0;
Required: Boolean = False);
The Add method takes the field name, the data type, the size (for string fields), and a flag
indicating whether the field is required as parameters. By using Add, the preceding code snippet
becomes the following single line of code:
ClientDataSet1.FieldDefs.Add(‘Name’, ftString, 20, True);
Why would you ever want to use AddFieldDef when you could use Add? One reason is that
TFieldDef contains several more-advanced properties (such as field precision, whether or not
it’s read-only, and a few other attributes) in addition to the four supported by Add. If you want
to set these properties for a field, you need to go through the TFieldDef. You should refer to
the Delphi documentation for TFieldDef for more details.
As you can see, it’s somewhat easier to create your client datasets at design-time than it is at
runtime. However, if you commonly create temporary in-memory datasets, or if you need to
create a client dataset in a formless unit, you can create the dataset at runtime with a minimal
amount of fuss.
Accessing Fields
Regardless of how you create the client dataset, at some point you need to access field infor-
mation—whether it’s for display, to calculate some values, or to add or modify a new record.
There are several ways to access field information in Delphi. The easiest is to use persistent fields.
Persistent Fields
Earlier in this chapter, when we used the field editor to create fields, we were also creating
persistent field objects for those fields. For example, when we added the LastName field,
Delphi created a persistent field object named cdsEmployeeLastName.
When you know the name of the field object, you can easily retrieve the contents of the field
by using the AsXxx family of methods. For example, to access a field as a string, you would
reference the AsString property, like this: 3
CLIENT DATASET
ShowMessage(‘The employee’’s last name is ‘ +
cdsEmployeeLastName.AsString);
BASICS
To retrieve the employee’s salary as a floating-point number, you would reference the AsFloat
property:
Bonus := cdsEmployeeSalary.AsFloat * 0.05;
See the VCL/CLX source code and the Delphi documentation for a list of available access
properties.
NOTE
You are not limited to accessing a field value in its native format. For example, just
because Salary is a currency field doesn’t mean you can’t attempt to access it as a
string. The following code displays an employee’s salary as a formatted currency:
ShowMessage(‘Your salary is ‘ + cdsEmployeeSalary.AsString);
You could access a string field as an integer, for example, if you knew that the field
contained an integer value. However, if you try to access a field as an integer (or
other data type) and the field doesn’t contain a value that’s compatible with that
data type, Delphi raises an exception.
Chapter 3
104
Nonpersistent Fields
If you create a dataset at design-time, you probably won’t have any persistent field objects. In
that case, there are a few methods you can use to access a field’s value.
The first is the FieldByName method. FieldByName takes the field name as a parameter and
returns a temporary field object. The following code snippet displays an employee’s last name
using FieldByName.
ShowMessage(‘The employee’’s last name is ‘ +
ClientDataSet1.FieldByName(‘LastName’).AsString);
CAUTION
If you call FieldByName with a nonexistent field name, Delphi raises an exception.
Another way to access the fields in a dataset is through the FindField method, like this:
if ClientDataSet1.FindField(‘LastName’) <> nil then
ShowMessage(‘Dataset contains a LastName field’);
Using this technique, you can create persistent fields for datasets created at runtime.
var
fldLastName: TField;
fldFirstName: TField;
begin
...
fldLastName := cds.FindField(‘LastName’);
fldFirstName := cds.FindField(‘FirstName’);
...
ShowMessage(‘The last name is ‘ + fldLastName.AsString);
end;
Finally, you can access the dataset’s Fields property. Fields contains a list of TField objects
for the dataset, as the following code illustrates:
var
Index: Integer;
begin
for Index := 0 to ClientDataSet1.Fields.Count - 1 do
ShowMessage(ClientDataSet1.Fields[Index].AsString);
end;
You do not normally access Fields directly. It is generally not safe programming practice to
assume, for example, that a given field is the first field in the Fields list. However, there are
Client Dataset Basics
105
times when the Fields list comes in handy. For example, if you have two client datasets with
the same structure, you could add a record from one dataset to the other using the following code:
var
Index: Integer;
begin
ClientDataSet2.Append;
for Index := 0 to ClientDataSet1.Fields.Count - 1 do
ClientDataSet2.Fields[Index].AsVariant :=
ClientDataSet1.Fields[Index].AsVariant;
ClientDataSet2.Post;
end;
CLIENT DATASET
BASICS
Populating Manually
The most basic way to enter data into a client dataset is through the Append and Insert methods,
which are supported by all datasets. The difference between them is that Append adds the new
record at the end of the dataset, but Insert places the new record immediately before the
current record.
I always use Append to insert new records because it’s slightly faster than Insert. If the dataset
is indexed, the new record is automatically sorted in the correct order anyway.
The following code snippet shows how to add a record to a client dataset:
cdsEmployee.Append; // You could use cdsEmployee.Insert; here as well
cdsEmployee.FieldByName(‘ID’).AsInteger := 5;
cdsEmployee.FieldByName(‘FirstName’).AsString := ‘Eric’;
cdsEmployee.Post;
Modifying Records
Modifying an existing record is almost identical to adding a new record. Rather than calling
Append or Insert to create the new record, you call Edit to put the dataset into edit mode. The
following code changes the first name of the current record to Fred.
Chapter 3
106
Deleting Records
To delete the current record, simply call the Delete method, like this:
cdsEmployee.Delete;
If you want to delete all records in the dataset, you can use EmptyDataSet instead, like this:
cdsEmployee.EmptyDataSet;
Similarly, to save the dataset to a stream, you call SaveToStream, which is defined as follows:
procedure SaveToStream(Stream: TStream; Format: TDataPacketFormat = dfBinary);
SaveToFile accepts the name of the file that you’re saving to. If the filename is blank, the data
is saved using the FileName property of the client dataset.
Both SaveToFile and SaveToStream take a parameter that indicates the format to use when
saving data. Client datasets can be stored in one of three file formats: binary, or either flavor of
XML. Table 3.1 lists the possible formats.
Client Dataset Basics
107
TABLE 3.1 Data Packet Formats for Loading and Saving Client Datasets
Value Description
dfBinary Data is stored using a proprietary, binary format.
dfXML Data is stored in XML format. Extended characters are represented
using an escape sequence.
dfXMLUTF8 Data is stored in XML format. Extended characters are represented
using UTF8.
When client datasets are stored to disk, they are referred to as MyBase files. MyBase stores
one dataset per file, or per stream, unless you use nested datasets.
NOTE
If you’re familiar with Microsoft ADO, you recall that ADO enables you to persist
datasets using XML format. The XML formats used by ADO and MyBase are not
compatible. In other words, you cannot save an ADO dataset to disk in XML format,
and then read it into a client dataset (or vice versa). 3
CLIENT DATASET
Sometimes, you need to determine how many bytes are required to store the data contained in
BASICS
the client dataset. For example, you might want to check to see if there is enough room on a
floppy disk before saving the data there, or you might need to preallocate the memory for a
stream. In these cases, you can check the DataSize property, like this:
if ClientDataSet1.DataSize > AvailableSpace then
ShowMessage(‘Not enough room to store the data’);
DataSize always returns the amount of space necessary to store the data in binary format
(dfBinary). XML format usually requires more space, perhaps twice as much (or even more).
NOTE
One way to determine the amount of space that’s required to save the dataset in
XML format is to save the dataset to a memory stream, and then obtain the size of
the resulting stream.
Chapter 3
108
interface
uses
SysUtils, Types, IdGlobal, Classes, QGraphics, QControls, QForms, QDialogs,
QStdCtrls, DB, DBClient, QExtCtrls, QGrids, QDBGrids, QActnList;
const
MAX_RECS = 10000;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
pnlBottom: TPanel;
btnPopulate: TButton;
btnSave: TButton;
btnLoad: TButton;
ActionList1: TActionList;
btnStatistics: TButton;
Populate1: TAction;
Statistics1: TAction;
Load1: TAction;
Save1: TAction;
DBGrid1: TDBGrid;
lblFeedback: TLabel;
procedure FormCreate(Sender: TObject);
procedure Populate1Execute(Sender: TObject);
procedure Statistics1Execute(Sender: TObject);
procedure Save1Execute(Sender: TObject);
procedure Load1Execute(Sender: TObject);
private
{ Private declarations }
FCDS: TClientDataSet;
public
Client Dataset Basics
109
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
CLIENT DATASET
procedure TfrmMain.Populate1Execute(Sender: TObject);
BASICS
const
FirstNames: array[0 .. 19] of string = (‘John’, ‘Sarah’, ‘Fred’, ‘Beth’,
‘Eric’, ‘Tina’, ‘Thomas’, ‘Judy’, ‘Robert’, ‘Angela’, ‘Tim’, ‘Traci’,
‘David’, ‘Paula’, ‘Bruce’, ‘Jessica’, ‘Richard’, ‘Carla’, ‘James’,
‘Mary’);
LastNames: array[0 .. 11] of string = (‘Parker’, ‘Johnson’, ‘Jones’,
‘Thompson’, ‘Smith’, ‘Baker’, ‘Wallace’, ‘Harper’, ‘Parson’, ‘Edwards’,
‘Mandel’, ‘Stone’);
var
Index: Integer;
t1, t2: DWord;
begin
RandSeed := 0;
t1 := GetTickCount;
FCDS.DisableControls;
try
FCDS.EmptyDataSet;
for Index := 1 to MAX_RECS do begin
FCDS.Append;
FCDS.FieldByName(‘ID’).AsInteger := Index;
FCDS.FieldByName(‘Name’).AsString := FirstNames[Random(20)] + ‘ ‘ +
Chapter 3
110
LastNames[Random(12)];
FCDS.FieldByName(‘Birthday’).AsDateTime := StrToDate(‘1/1/1950’) +
Random(10000);
FCDS.FieldByName(‘Salary’).AsFloat := 20000.0 + Random(600) * 100;
FCDS.Post;
end;
FCDS.First;
finally
FCDS.EnableControls;
end;
t2 := GetTickCount;
lblFeedback.Caption := Format(‘%d ms to load %.0n records’,
[t2 - t1, MAX_RECS * 1.0]);
end;
FCDS.First;
t1 := GetTickCount;
FCDS.Locate(‘Name’, ‘Eric Wallace’, []);
t2 := GetTickCount;
msLocateName := t2 - t1;
FCDS.SaveToFile(‘C:\Employee.cds’);
t2 := GetTickCount;
lblFeedback.Caption := Format(‘%d ms to save data’, [t2 - t1]);
end;
CLIENT DATASET
end.
BASICS
There are five methods in this application and each one is worth investigating:
• FormCreate creates the client dataset and its schema at runtime. It would actually be easier
to create the dataset at design-time, but I wanted to show you the code required to do this
at runtime. The code creates four fields: Employee ID, Name, Birthday, and Salary.
• Populate1Execute loads the client dataset with 10,000 employees made up of random
data. At the beginning of the method, I manually set RandSeed to 0 to ensure that multiple
executions of the application would generate the same data.
NOTE
The Delphi Randomizer normally seeds itself with the current date and time. By
manually seeding the Randomizer with a constant value, we can ensure that the random
numbers generated are consistent every time we run the program.
• The method calculates approximately how long it takes to generate the 10,000 employees,
which on my computer is about half of a second.
Chapter 3
112
NOTE
Please make sure that you click the Save button because the file created
(C:\EMPLOYEE.CDS) is used in the rest of the example applications in this chapter, as
well as some of the examples in the following chapter.
• Load1Execute loads the data from a file into the client dataset. If LoadFromFile fails
(presumably because the file doesn’t exist or is not a valid file format), the client dataset
is left in a closed state. For this reason, I reopen the client dataset when an exception is
raised.
Figure 3.5 shows the CDS application running on my computer. Note the impressive times
posted to locate a record. Even when searching through almost the entire dataset to find ID
9763, it only takes approximately 10 ms on my computer.
FIGURE 3.5
The CDS application at runtime.
Client Dataset Basics
113
Sequential Navigation
The most basic way to navigate through a dataset is sequentially in either forward or reverse
order. For example, you might want to iterate through a dataset when printing a report, or for
some other reason. Delphi provides four simple methods to accomplish this:
• First moves to the first record in the dataset. First always succeeds, even if the dataset
is empty. If it is empty, First sets the dataset’s EOF (end of file) property to True.
• Next moves to the next record in the dataset (if the EOF property is not already set). If
EOF is True, Next will fail. If the call to Next reaches the end of the file, it sets the EOF
property to True.
• Lastmoves to the last record in the dataset. Last always succeeds, even if the dataset is
empty. If it is empty, Last sets the dataset’s BOF (beginning of file) property to True. 3
• moves to the preceding record in the dataset (if the BOF property is not already
CLIENT DATASET
Prior
set). If BOF is True, Prior will fail. If the call to Prior reaches the beginning of the file,
BASICS
it sets the BOF property to True.
The following code snippet shows how you can use these methods to iterate through a dataset:
if not ClientDataSet1.IsEmpty then begin
ClientDataSet1.First;
while not ClientDataSet1.EOF do begin
// Process the current record
ClientDataSet1.Next;
end;
ClientDataSet1.Last;
while not ClientDataSet1.BOF do begin
// Process the current record
ClientDataSet1.Prior;
end;
end;
Chapter 3
114
Random-Access Navigation
In addition to First, Next, Prior, and Last (which provide for sequential movement through a
dataset), TClientDataSet provides two ways of moving directly to a given record: bookmarks
and record numbers.
Bookmarks
A bookmark used with a client dataset is very similar to a bookmark used with a paper-based
book: It marks a location in a dataset so that you can quickly return to it later.
There are three operations that you can perform with bookmarks: set a bookmark, return to a
bookmark, and free a bookmark. The following code snippet shows how to do all three:
var
Bookmark: TBookmark;
begin
Bookmark := ClientDataSet1.GetBookmark;
try
// Do something with ClientDataSet1 here that changes the current record
...
ClientDataSet1.GotoBookmark(Bookmark);
finally
ClientDataSet1.FreeBookmark(Bookmark);
end;
end;
You can create as many bookmarks as you want for a dataset. However, keep in mind that a
bookmark allocates a small amount of memory, so you should be sure to free all bookmarks
using FreeBookmark or your application will leak memory.
There is a second set of operations that you can use for bookmarks instead of
GetBookmark/GotoBookmark/FreeBookmark. The following code shows this alternate method:
var
BookmarkStr: string;
begin
BookmarkStr := ClientDataSet1.Bookmark;
try
// Do something with ClientDataSet1 here that changes the current record
...
finally
ClientDataSet1.Bookmark := BookmarkStr;
end;
end;
Client Dataset Basics
115
Because the bookmark returned by the property, Bookmark, is a string, you don’t need to concern
yourself with freeing the string when you’re done. Like all strings, Delphi automatically frees
the bookmark when it goes out of scope.
Record Numbers
Client datasets support a second way of moving directly to a given record in the dataset: setting
the RecNo property of the dataset. RecNo is a one-based number indicating the sequential
number of the current record relative to the beginning of the dataset.
You can read the RecNo property to determine the current absolute record number, and write
the RecNo property to set the current record. There are two important things to keep in mind
with respect to RecNo:
• Attempting to set RecNo to a number less than one, or to a number greater than the number
of records in the dataset results in an At beginning of table, or an At end of table
exception, respectively.
• The record number of any given record is not guaranteed to be constant. For instance,
changing the active index on a dataset alters the record number of all records in the
dataset. 3
CLIENT DATASET
NOTE
BASICS
You can determine the number of records in the dataset by inspecting the dataset’s
RecordCount property. When setting RecNo, never attempt to set it to a number
higher than RecordCount.
However, when used discriminately, RecNo has its uses. For example, let’s say the user of your
application wants to delete all records between the John Smith record and the Fred Jones
record. The following code shows how you can accomplish this:
var
RecNoJohn: Integer;
RecNoFred: Integer;
Index: Integer;
begin
if not ClientDataSet1.Locate(‘Name’, ‘John Smith’, []) then
raise Exception.Create(‘Cannot locate John Smith’);
RecNoJohn := ClientDataSet1.RecNo;
This code snippet first locates the two bounding records and remembers their absolute record
numbers. Then, it positions the dataset to the lower record number. If Fred occurs before John,
the dataset is already positioned at the lower record number.
Because records are sequentially numbered, we can subtract the two record numbers (and add
one) to determine the number of records to delete. Deleting a record makes the next record
current, so a simple for loop handles the deletion of the records.
Keep in mind that RecNo isn’t usually going to be your first line of attack for moving around in
a dataset, but it’s handy to remember that it’s available if you ever need it.
Listing 3.2 contains the complete source code for an application that demonstrates the different
navigational methods of client datasets.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QStdCtrls,
DB, DBClient, QExtCtrls, QActnList, QGrids, QDBGrids, QDBCtrls;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
pnlBottom: TPanel;
btnFirst: TButton;
btnLast: TButton;
btnNext: TButton;
btnPrior: TButton;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
btnSetRecNo: TButton;
DBNavigator1: TDBNavigator;
btnGetBookmark: TButton;
Client Dataset Basics
117
btnGotoBookmark: TButton;
procedure FormCreate(Sender: TObject);
procedure btnNextClick(Sender: TObject);
procedure btnLastClick(Sender: TObject);
procedure btnSetRecNoClick(Sender: TObject);
procedure btnFirstClick(Sender: TObject);
procedure btnPriorClick(Sender: TObject);
procedure btnGetBookmarkClick(Sender: TObject);
procedure btnGotoBookmarkClick(Sender: TObject);
private
{ Private declarations }
FBookmark: TBookmark;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
3
CLIENT DATASET
{$R *.xfm}
BASICS
procedure TfrmMain.FormCreate(Sender: TObject);
begin
ClientDataSet1.LoadFromFile(‘C:\Employee.cds’);
end;
begin
ClientDataSet1.Last;
end;
FBookmark := ClientDataSet1.GetBookmark;
end;
end.
• To enable the client dataset to be sorted on-the-fly. This is useful when you want to order
the data in a data-aware grid, for example.
• To implement maintained aggregates.
FIGURE 3.6
The Navigate application demonstrates various navigational techniques.
3
Creating Indexes
CLIENT DATASET
Like field definitions, indexes can be created at design-time or at runtime. Unlike field
BASICS
definitions, which are usually created at design-time, you might want to create and destroy
indexes at runtime. For example, some indexes are only used for a short time—say, to create a
report in a certain order. In this case, you might want to create the index, use it, and then
destroy it. If you constantly need an index, it’s better to create it at design-time (or to create it
the first time you need it and not destroy it afterward).
Table 3.3 shows the various index options that can be set using the Options property.
You can create multiple indexes on a single dataset. So, you can easily have both an ascending
and a descending index on EmployeeName, for example.
Client Dataset Basics
121
The parameters correspond to the TIndexDef properties listed in Table 3.2. The following code
snippet shows how to create a unique index by last and first names: 3
ClientDataSet1.AddIndex(‘byName’, ‘Last;First’, [ixUnique]);
CLIENT DATASET
When you decide that you no longer need an index (remember, you can always re-create it if
BASICS
you need it later), you can delete it using DeleteIndex. DeleteIndex takes a single parameter:
the name of the index being deleted. The following line of code shows how to delete the index
created in the preceding code snippet:
ClientDataSet1.DeleteIndex(‘byName’);
Using Indexes
Creating an index doesn’t perform any actual sorting of the dataset. It simply creates an available
index to the data. After you create an index, you make it active by setting the dataset’s
IndexName property, like this:
ClientDataSet1.IndexName := ‘byName’;
If you have two or more indexes defined on a dataset, you can quickly switch back and forth
by changing the value of the IndexName property. If you want to discontinue the use of an
index and revert to the default record order, you can set the IndexName property to an empty
string, as the following code snippet illustrates:
// Do something in name order
ClientDataSet1.IndexName := ‘byName’;
Chapter 3
122
There is a second way to specify indexes on-the-fly at runtime. Instead of creating an index
and setting the IndexName property, you can simply set the IndexFieldNames property.
IndexFieldNames accepts a semicolon-delimited list of fields to index on. The following code
shows how to use it:
ClientDataSet1.IndexFieldNames := ‘Last;First’;
Though IndexFieldNames is quicker and easier to use than AddIndex/IndexName, its simplicity
does not come without a price. Specifically,
• You cannot set any index options, such as unique or descending indexes.
• You cannot specify a grouping level or create maintained aggregates.
• When you switch from one index to another (by changing the value of
IndexFieldNames), the old index is automatically dropped. If you switch back at a later
time, the index is re-created. This happens so fast that it’s not likely to be noticeable, but
you should be aware that it’s happening, nonetheless. When you create indexes using
AddIndex, the index is maintained until you specifically delete it using DeleteIndex.
NOTE
Though you can switch back and forth between IndexName and IndexFieldNames in
the same application, you can’t set both properties at the same time. Setting
IndexName clears IndexFieldNames, and setting IndexFieldNames clears IndexName.
GetIndexNames
The simplest method for retrieving index information is GetIndexNames. GetIndexNames takes
a single parameter, a TStrings object, in which to store the resultant index names. The follow-
ing code snippet shows how to load a list box with the names of all indexes defined for a
dataset.
ClientDataSet1.GetIndexNames(ListBox1.Items);
Client Dataset Basics
123
CAUTION
If you execute this code on a dataset for which you haven’t defined any indexes,
you’ll notice that there are two indexes already defined for you: DEFAULT_ORDER and
CHANGEINDEX. DEFAULT_ORDER is used internally to provide records in nonindexed
order. CHANGEINDEX is used internally to provide undo support, which is discussed later
in this chapter. You should not attempt to delete either of these indexes.
TIndexDefs
If you want to obtain more detailed information about an index, you can go directly to the
source: TIndexDefs. TIndexDefs contains a list of all indexes, along with the information
associated with each one (such as the fields that make up the index, which fields are descend-
ing, and so on).
The following code snippet shows how to access index information directly through
TIndexDefs.
var
Index: Integer;
3
CLIENT DATASET
IndexDef: TIndexDef;
begin
BASICS
ClientDataSet1.IndexDefs.Update;
Notice the call to IndexDefs.Update before the code that loops through the index definitions.
This call is required to ensure that the internal IndexDefs list is up-to-date. Without it, it’s pos-
sible that IndexDefs might not contain any information about recently added indexes.
The following application demonstrates how to provide on-the-fly indexing in a TDBGrid. It
also contains code for retrieving detailed information about all the indexes defined on a
dataset.
Figure 3.7 shows the CDSIndex application at runtime, as it displays index information for the
employee client dataset.
Listing 3.3 contains the complete source code for the CDSIndex application.
Chapter 3
124
FIGURE 3.7
CDSIndex shows how to create indexes on-the-fly.
unit MainForm;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QStdCtrls,
DB, DBClient, QExtCtrls, QActnList, QGrids, QDBGrids;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
pnlBottom: TPanel;
btnDefaultOrder: TButton;
btnIndexList: TButton;
ListBox1: TListBox;
procedure FormCreate(Sender: TObject);
procedure DBGrid1TitleClick(Column: TColumn);
procedure btnDefaultOrderClick(Sender: TObject);
procedure btnIndexListClick(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
Client Dataset Basics
125
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
CLIENT DATASET
ClientDataSet1.IndexName := ‘byUser’;
end;
BASICS
procedure TfrmMain.btnDefaultOrderClick(Sender: TObject);
begin
// Deleting the current index will revert to the default order
try
ClientDataSet1.DeleteIndex(‘byUser’);
except
end;
ClientDataSet1.IndexFieldNames := ‘’;
end;
ListBox1.Items.BeginUpdate;
try
ListBox1.Items.Clear;
Chapter 3
126
end.
The code to dynamically sort the grid at runtime is contained in the method
DBGrid1TitleClick. First, it attempts to delete the temporary index named byUser, if it exists.
If it doesn’t exist, an exception is raised, which the code simply eats. A real application should
not mask exceptions willy-nilly. Instead, it should trap for the specific exceptions that might be
thrown by the call to DeleteIndex, and let the others be reported to the user.
The method then creates a new index named byUser, and sets it to be the current index.
NOTE
Though this code works, it is rudimentary at best. There is no support for sorting on
multiple grid columns, and no visual indication of what column(s) the grid is sorted
by. For an elegant solution to these issues, I urge you to take a look at John Kaster’s
TCDSDBGrid (available as ID 15099 on Code Central at
http://codecentral.borland.com).
Ranges
Ranges are useful when the data that you want to limit yourself to is stored in a consecutive
sequence of records. For example, say a dataset contains the data shown in Table 3.4.
Client Dataset Basics
127
The data in this much-abbreviated table is indexed by birthday. Ranges can only be used when
there is an active index on the dataset.
Assume that you want to see all employees who were born between 1960 and 1970. Because
the data is indexed by birthday, you could apply a range to the dataset, like this:
ClientDataSet1.SetRange([‘1/1/1960’], [‘12/31/1970’]);
Ranges are inclusive, meaning that the endpoints of the range are included within the range. In
the preceding example, employees who were born on either January 1, 1960 or December 31, 3
1970 are included in the range.
CLIENT DATASET
To remove the range, simply call CancelRange, like this:
BASICS
ClientDataSet1.CancelRange;
Filters
Unlike ranges, filters do not require an index to be set before applying them. Client dataset
filters are powerful, offering many SQL-like capabilities, and a few options that are not even
supported by SQL. Tables 3.5–3.10 list the various functions and operators available for use in
a filter.
CLIENT DATASET
of a time value in 24-hour
format.
BASICS
Minute Returns the minute portion Minute(Appointment) = 30
of a time value.
Second Returns the second portion Second(Appointment) = 0
of a time value.
GetDate Returns the current date Appointment < GetDate
and time.
Date Returns the date portion Date(Appointment)
of a date/time value.
Time Returns the time portion Time(Appointment)
of a date/time value.
To filter a dataset, set its Filter property to the string used for filtering, and then set the
Filtered property to True. For example, the following code snippet filters out all employees
whose names begin with the letter M.
ClientDataSet1.Filter := ‘Name LIKE ‘ + QuotedStr(‘M%’);
ClientDataSet1.Filtered := True;
To later display only those employees whose names begin with the letter P, simply change the
filter, like this:
ClientDataSet1.Filter := ‘Name LIKE ‘ + QuotedStr(‘P%’);
To remove the filter, set the Filtered property to False. You don’t have to set the Filter
property to an empty string to remove the filter (which means that you can toggle the most
recent filter on and off by switching the value of Filtered from True to False).
You can apply more advanced filter criteria by handling the dataset’s OnFilterRecord event
(instead of setting the Filter property). For example, say that you want to filter out all
employees whose last names sound like Smith. This would include Smith, Smythe, and possibly
others. Assuming that you have a Soundex function available, you could write a filter method
like the following:
procedure TForm1.ClientDataSet1FilterRecord(DataSet: TDataSet;
var Accept: Boolean);
begin
Accept := Soundex(DataSet.FieldByName(‘LastName’).AsString) =
Soundex(‘Smith’);
end;
If you set the Accept parameter to True, the record is included in the filter. If you set Accept
to False, the record is hidden.
After you set up an OnFilterRecord event handler, you can simply set
TClientDataSet.Filtered to True. You don’t need to set the Filter property at all.
unit MainForm;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QStdCtrls,
DB, DBClient, QExtCtrls, QGrids, QDBGrids;
Client Dataset Basics
131
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
pnlBottom: TPanel;
btnFilter: TButton;
btnRange: TButton;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
btnClearRange: TButton;
btnClearFilter: TButton;
procedure FormCreate(Sender: TObject);
procedure btnFilterClick(Sender: TObject);
procedure btnRangeClick(Sender: TObject);
procedure btnClearRangeClick(Sender: TObject);
procedure btnClearFilterClick(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
3
end;
CLIENT DATASET
BASICS
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
try
if frmFilter.ShowModal = mrOk then begin
ClientDataSet1.Filter := frmFilter.Filter;
ClientDataSet1.Filtered := True;
end;
finally
frmFilter.Free;
end;
end;
end.
As you can see, the main form loads the employee dataset from a disk, creates an index on the
Salary field, and makes the index active. It then enables the user to apply a range, a filter, or
both to the dataset.
Listing 3.5 contains the source code for the filter form. The filter form is a simple form that
enables the user to select the field on which to filter, and to enter a value on which to filter.
Client Dataset Basics
133
unit FilterForm;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QStdCtrls,
QExtCtrls;
type
TfrmFilter = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
Label1: TLabel;
cbField: TComboBox;
Label2: TLabel;
cbRelationship: TComboBox;
Label3: TLabel;
ecValue: TEdit;
btnOk: TButton;
btnCancel: TButton; 3
private
CLIENT DATASET
function GetFilter: string;
BASICS
{ Private declarations }
public
{ Public declarations }
property Filter: string read GetFilter;
end;
implementation
{$R *.xfm}
{ TfrmFilter }
end.
The only interesting code in this form is the GetFilter function, which simply bundles the
values of the three input controls into a filter string and returns it to the main application.
Chapter 3
134
Listing 3.6 contains the source code for the range form. The range form prompts the user for a
lower and an upper salary limit.
unit RangeForm;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QExtCtrls,
QStdCtrls;
type
TfrmRange = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
Label1: TLabel;
Label2: TLabel;
ecLower: TEdit;
ecUpper: TEdit;
btnOk: TButton;
btnCancel: TButton;
procedure btnOkClick(Sender: TObject);
private
function GetHighValue: Double;
function GetLowValue: Double;
{ Private declarations }
public
{ Public declarations }
property LowValue: Double read GetLowValue;
property HighValue: Double read GetHighValue;
end;
implementation
{$R *.xfm}
{ TfrmRange }
CLIENT DATASET
end;
BASICS
end.
FIGURE 3.8
RangeFilter applies both ranges and filters to a dataset.
Chapter 3
136
Searching
In addition to filtering out uninteresting records from a client dataset, TClientDataSet provides
a number of methods for quickly locating a specific record. Some of these methods require an
index to be active on the dataset, and others do not. The search methods are described in detail
in the following sections.
Locate
Locate is perhaps the most general purpose of the TClientDataSet search methods. You can
use Locate to search for a record based on any given field or combination of fields. Locate can
also search for records based on a partial match, and can find a match without respect to case.
TClientDataSet.Locate is defined like this:
function Locate(const KeyFields: string; const KeyValues: Variant;
Options: TLocateOptions): Boolean; override;
The first parameter, KeyFields, designates the field (or fields) to search. When searching multiple
fields, separate them by semicolons (for example, ‘Name;Birthday’).
The second parameter, KeyValues, represents the values to search for. The number of values
must match the number of key fields exactly. If there is only one search field, you can simply
pass the value to search for here. To search for multiple values, you must pass the values as a
variant array. One way to do this is by calling VarArrayOf, like this:
VarArrayOf([‘John Smith’, ‘4/15/1965’])
The final parameter, Options, is a set that determines how the search is to be executed. Table
3.11 lists the available options.
Both options pertain to string fields only. They are ignored if you specify them for a nonstring
search.
Locate returns True if a matching record is found, and False if no match is found. In case
of a match, the record is made current.
The following examples help illustrate the options:
ClientDataSet1.Locate(‘Name’, ‘John Smith’, []);
This searches for a record where the name begins with ‘JOHN’. This finds ‘John Smith’,
‘Johnny Jones’, and ‘JOHN ADAMS’, but not ‘Bill Johnson’.
This searches for a record where the name begins with ‘John’ and the birthday is April 15,
1965. In this case, the loPartialKey option applies to the name only. Even though the birthday
is passed as a string, the underlying field is a date field, so the loPartialKey option is ignored 3
for that field only.
CLIENT DATASET
BASICS
Lookup
Lookup is similar in concept to Locate, except that it doesn’t change the current record pointer.
Instead, Lookup returns the values of one or more fields in the record. Also, Lookup does not
accept an Options parameter, so you can’t perform a lookup that is based on a partial key or
that is not case sensitive.
Lookup is defined like this:
function Lookup(const KeyFields: string; const KeyValues: Variant;
const ResultFields: string): Variant; override;
KeyFields and KeyValues specify the fields to search and the values to search for, just as with
the Locate method. ResultFields specifies the fields for which you want to return data. For
example, to return the birthday of the employee named John Doe, you could write the
following code:
var
V: Variant;
begin
V := ClientDataSet1.Lookup(‘Name’, ‘John Doe’, ‘Birthday’);
end;
Chapter 3
138
The following code returns the name and birthday of the employee with ID number 100.
var
V: Variant;
begin
V := ClientDataSet1.Lookup(‘ID’, 100, ‘Name;Birthday’);
end;
If the requested record is not found, V is set to NULL. If ResultFields contains a single field
name, then on return from Lookup, V is a variant containing the value of the field listed in
ResultFields. If ResultFields contains multiple single-field names, then on return from
Lookup, V is a variant array containing the values of the fields listed in ResultFields.
NOTE
For a comprehensive discussion of variant arrays, see my book, Delphi COM
Programming, published by Macmillan Technical Publishing.
The following code snippet shows how you can access the results that are returned from Lookup.
var
V: Variant;
begin
V := ClientDataSet1.Lookup(‘ID’, 100, ‘Name’);
if not VarIsNull(V) then
ShowMessage(‘ID 100 refers to ‘ + V);
FindKey
FindKey searches for an exact match on the key fields of the current index. For example, if the
dataset is currently indexed by ID, FindKey searches for an exact match on the ID field. If the
dataset is indexed by last and first name, FindKey searches for an exact match on both the last
and the first name.
Client Dataset Basics
139
FindKey takes a single parameter, which specifies the value(s) to search for. It returns a
Boolean value that indicates whether a matching record was found. If no match was found, the
current record pointer is unchanged. If a matching record is found, it is made current.
The parameter to FindKey is actually an array of values, so you need to put the values in
brackets, as the following examples show:
if ClientDataSet.FindKey([25]) then
ShowMessage(‘Found ID 25’);
...
if ClientDataSet.FindKey([‘Doe’, ‘John’]) then
ShowMessage(‘Found John Doe’);
You need to ensure that the values you search for match the current index. For that reason, you
might want to set the index before making the call to FindKey. The following code snippet
illustrates this:
ClientDataSet1.IndexName := ‘byID’;
if ClientDataSet.FindKey([25]) then
ShowMessage(‘Found ID 25’);
...
ClientDataSet1.IndexName := ‘byName’; 3
if ClientDataSet.FindKey([‘Doe’, ‘John’]) then
CLIENT DATASET
ShowMessage(‘Found John Doe’);
BASICS
FindNearest
FindNearest works similarly to FindKey, except that it finds the first record that is greater
than or equal to the value(s) passed to it. This depends on the current value of the
KeyExclusive property.
If KeyExclusive is False (the default), FindNearest finds the first record that is greater than
or equal to the passed-in values. If KeyExclusive is True, FindNearest finds the first record
that is greater than the passed-in values.
If FindNearest doesn’t find a matching record, it moves the current record pointer to the end
of the dataset.
GotoKey
GotoKey performs the same function as FindKey, except that you set the values of the search
field(s) before calling GotoKey. The following code snippet shows how to do this:
ClientDataSet1.IndexName := ‘byID’;
ClientDataSet1.SetKey;
ClientDataSet1.FieldByName(‘ID’).AsInteger := 25;
ClientDataSet1.GotoKey;
Chapter 3
140
If the index is made up of multiple fields, you simply set each field after the call to SetKey,
like this:
ClientDataSet1.IndexName := ‘byName’;
ClientDataSet1.SetKey;
ClientDataSet1.FieldByName(‘First’).AsString := ‘John’;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Doe’;
ClientDataSet1.GotoKey;
After calling GotoKey, you can use the EditKey method to edit the key values used for the
search. For example, the following code snippet shows how to search for John Doe, and then
later search for John Smith. Both records have the same first name, so only the last name
portion of the key needs to be specified during the second search.
ClientDataSet1.IndexName := ‘byName’;
ClientDataSet1.SetKey;
ClientDataSet1.FieldByName(‘First’).AsString := ‘John’;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Doe’;
ClientDataSet1.GotoKey;
// Do something with the record
GotoNearest
GotoNearest works similarly to GotoKey, except that it finds the first record that is greater than
or equal to the value(s) passed to it. This depends on the current value of the KeyExclusive
property.
If KeyExclusive is False (the default), GotoNearest finds the first record that is greater than
or equal to the field values set after a call to either SetKey or EditKey. If KeyExclusive is
True, GotoNearest finds the first record that is greater than the field values set after calling
SetKey or EditKey.
If GotoNearest doesn’t find a matching record, it moves the current record pointer to the end
of the dataset.
The following example shows how to perform indexed and nonindexed searches on a dataset.
Listing 3.7 shows the source code for the Search application, a sample program that illustrates
the various indexed and nonindexed searching techniques supported by TClientDataSet.
Client Dataset Basics
141
unit MainForm;
interface
uses
SysUtils, Classes, Variants, QGraphics, QControls, QForms, QDialogs,
QStdCtrls, DB, DBClient, QExtCtrls, QActnList, QGrids, QDBGrids;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
pnlBottom: TPanel;
btnSearch: TButton;
btnGotoBookmark: TButton;
btnGetBookmark: TButton;
btnLookup: TButton;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
btnSetRecNo: TButton;
3
procedure FormCreate(Sender: TObject);
CLIENT DATASET
procedure btnGetBookmarkClick(Sender: TObject);
BASICS
procedure btnGotoBookmarkClick(Sender: TObject);
procedure btnSetRecNoClick(Sender: TObject);
procedure btnSearchClick(Sender: TObject);
procedure btnLookupClick(Sender: TObject);
private
{ Private declarations }
FBookmark: TBookmark;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
uses SearchForm;
{$R *.xfm}
ClientDataSet1.LoadFromFile(‘C:\Employee.cds’);
FBookmark := ClientDataSet1.GetBookmark;
end;
smFindKey:
ClientDataSet1.FindKey([frmSearch.ecName.Text]);
smFindNearest:
ClientDataSet1.FindNearest([frmSearch.ecName.Text]);
smGotoKey: begin
ClientDataSet1.SetKey;
ClientDataSet1.FieldByName(‘Name’).AsString :=
frmSearch.ecName.Text;
ClientDataSet1.GotoKey;
end;
smGotoNearest: begin
ClientDataSet1.SetKey;
ClientDataSet1.FieldByName(‘Name’).AsString :=
frmSearch.ecName.Text;
ClientDataSet1.GotoNearest;
end;
end;
3
CLIENT DATASET
end;
finally
BASICS
frmSearch.Free;
end;
end;
end.
Chapter 3
144
Listing 3.8 contains the source code for the search form. The only interesting bit of code in this
listing is the TSearchMethod, defined near the top of the unit, which is used to determine what
method to call for the search.
unit SearchForm;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QExtCtrls,
QStdCtrls;
type
TSearchMethod = (smLocate, smFindKey, smFindNearest, smGotoKey,
smGotoNearest);
TfrmSearch = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
Label1: TLabel;
ecName: TEdit;
grpMethod: TRadioGroup;
btnOk: TButton;
btnCancel: TButton;
private
{ Private declarations }
public
{ Public declarations }
end;
implementation
{$R *.xfm}
end.
FIGURE 3.9
Search demonstrates indexed and nonindexed searches.
Summary
TClientDataSet is an extremely powerful in-memory dataset that supports a number of
high-performance sorting and searching operations. Following are several key points to take
away from this chapter: 3
CLIENT DATASET
• You can create client datasets both at design-time and at runtime. This chapter showed
how to save them to a disk for use in single-tier database applications.
BASICS
• The three basic ways of populating a client dataset are
Manually with Append or Insert
From another dataset
From a file or stream (that is, via persisting client datasets)
• Datasets in Delphi can be navigated in a variety of ways: sequentially, via bookmarks,
and via record numbers.
• You can create indexes on a dataset enabling you to quickly and easily sort the records in
a given order, and to locate records that match a certain criteria.
• Filters and ranges can be used to limit the amount of data that is visible in the dataset.
Ranges are useful when the relevant data is stored in a consecutive sequence of records.
Unlike ranges, filters do not require an index to be set before applying them.
• Locate and Lookup are nonindexed search techniques for locating a specific record in a
client dataset. FindKey, FindNearest, GotoKey, and GotoNearest are indexed search
techniques.
In the following chapter, I’ll discuss more advanced client dataset functionality.
Advanced Client Dataset CHAPTER
4
Operations
IN THIS CHAPTER
• Dataset Events 148
• BLOBs 162
The preceding chapter introduced you to TClientDataSet and discussed much of its basic
functionality in detail. In this chapter, I’ll explore a number of more advanced client dataset
capabilities, including:
• Dataset Events
• Disabling Data-Aware Components
• BLOBs
• Nested Datasets
• Undo Support
• Cloning Data from Another Client Dataset
• Maintained Aggregates
• Miscellaneous Properties
Dataset Events
Client datasets support a large number of events. Some of these events are useful in single-tier
applications (such as the ones we’re developing in this chapter), and some are only useful in
multitier applications (which we’ll be developing in future chapters).
This chapter discusses dataset events that are useful in all applications, including single-tier
and multitier. Broadly speaking, these events fall into three categories: BeforeXxx notification
events, AfterXxx notification events, and other events. BeforeXxx and AfterXxx notification
events are fired by Delphi before and after interesting activities occur. For purposes of this
discussion, interesting refers to normal, everyday activities that the dataset performs—activities
for which you want to receive notification when they occur.
An example will help to clarify that last statement: Say that you want to verify all deletions
from a dataset. One way to do this is to display a confirmation message to the user at every
point in the program where you allow a deletion to take place. This method has three
drawbacks, however:
• It is repetitive.
• It is prone to error. If you change the confirmation message in one location in your code,
you can easily forget to change the message in other locations. You might also forget to
implement the confirmation altogether.
• It doesn’t work in cases where VCL/CLX implicitly deletes a record. If you press
Ctrl+Delete while in a data-aware grid, VCL/CLX handles the deletion for you with no
coding on your part.
Advanced Client Dataset Operations
149
A better way to code for this situation is to handle the BeforeDelete event and display a message
there asking the user if he is sure he wants to delete the record. As with most BeforeXxx event
handlers, raising an exception (usually Abort) inside the event handler prevents the operation
from taking place.
Later in this section, I’ll present a sample application that illustrates this technique.
Table 4.1 lists the client dataset’s BeforeXxx events and their uses.
ADVANCED CLIENT
BeforeScroll Triggered just before the dataset moves to a new record. This occurs OPERATIONS
when the dataset is opened during a First, Next, Prior, or Last DATASET
operation; during searches; and when a range or filter is applied
to the dataset.
In contrast to the BeforeXxx event handlers, which are triggered before an event actually
occurs (and therefore enable you to prevent the event from occurring), AfterXxx event han-
dlers are triggered after the event has occurred to let you know that the operation in question
has occurred successfully.
Table 4.2 lists the AfterXxx event handlers, which mirror the BeforeXxx event handlers.
Chapter 4
150
Table 4.3 lists the client dataset’s other notable event handlers.
You can learn a lot about how datasets work by handling all these events and logging their
calls to either a log file or a list box. In the following example, I’ve done just that. Listing 4.1
contains the complete source code for the EventLog application.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, DB, DBClient, QComCtrls, QGrids, QDBGrids, QExtCtrls, QStdCtrls,
QDBCtrls;
type
TLogEventType = (logBeforeCancel, logBeforeClose, logBeforeDelete,
logBeforeEdit, logBeforeInsert, logBeforeOpen, logBeforePost,
logBeforeScroll, logAfterCancel, logAfterClose, logAfterDelete,
logAfterEdit, logAfterInsert, logAfterOpen, logAfterPost, logAfterScroll);
TfrmMain = class(TForm)
ClientDataSet1: TClientDataSet;
pnlClient: TPanel;
pnlLog: TPanel;
grid: TDBGrid;
lvLog: TListView;
DataSource1: TDataSource;
pnlBottom: TPanel;
btnConnect: TButton;
lblRecPos: TLabel; 4
ADVANCED CLIENT
btnDisconnect: TButton;
btnClearLog: TButton; OPERATIONS
DBNavigator1: TDBNavigator; DATASET
btnOptions: TButton;
procedure ClientDataSet1AfterCancel(DataSet: TDataSet);
procedure ClientDataSet1AfterClose(DataSet: TDataSet);
procedure ClientDataSet1AfterDelete(DataSet: TDataSet);
procedure ClientDataSet1AfterEdit(DataSet: TDataSet);
procedure ClientDataSet1AfterInsert(DataSet: TDataSet);
procedure ClientDataSet1AfterOpen(DataSet: TDataSet);
procedure ClientDataSet1AfterPost(DataSet: TDataSet);
procedure ClientDataSet1AfterScroll(DataSet: TDataSet);
procedure btnConnectClick(Sender: TObject);
Chapter 4
152
var
frmMain: TfrmMain;
implementation
uses OptionsForm;
{$R *.xfm}
ADVANCED CLIENT
OPERATIONS
procedure TfrmMain.ClientDataSet1AfterOpen(DataSet: TDataSet);
DATASET
begin
Log(logAfterOpen);
end;
ADVANCED CLIENT
frmOptions := TfrmOptions.Create(nil);
try
OPERATIONS
DATASET
frmOptions.LogScrollEvents := FLogScrollEvents;
frmOptions.PromptOnDelete := FPromptOnDelete;
end.
Chapter 4
156
Figure 4.1 shows the application at runtime. A data-aware grid occupies the top half of the
main form enabling the user to insert, edit, and delete records. The middle section of the form
contains a list view, which records a log of all activities performed on the dataset. On the bot-
tom of the form are buttons to open and close the dataset, clear the log, and set options for the
demo.
The user can select whether to log scroll events and whether to verify deletions by clicking the
Options… button at the bottom of the main form, as Figure 4.1 illustrates.
FIGURE 4.1
The EventLog application demonstrates client dataset events.
Listing 4.2 contains the source code for the application’s Options form, which allows the user
to specify whether the program should prompt him when deleting a record, and whether the
program should log dataset events to a list box.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QStdCtrls, QExtCtrls;
type
TfrmOptions = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
Advanced Client Dataset Operations
157
btnOk: TButton;
btnCancel: TButton;
cbLogScrollEvents: TCheckBox;
cbPromptOnDelete: TCheckBox;
private
procedure SetLogScrollEvents(const Value: Boolean);
function GetLogScrollEvents: Boolean;
function GetPromptOnDelete: Boolean;
procedure SetPromptOnDelete(const Value: Boolean);
{ Private declarations }
public
{ Public declarations }
property LogScrollEvents: Boolean
read GetLogScrollEvents write SetLogScrollEvents;
property PromptOnDelete: Boolean
read GetPromptOnDelete write SetPromptOnDelete;
end;
implementation
{$R *.xfm}
{ TfrmOptions }
ADVANCED CLIENT
begin
OPERATIONS
Result := cbPromptOnDelete.Checked; DATASET
end;
end.
Chapter 4
158
This code loops through all records in the dataset, giving anyone who makes less than $30,000
per year a 5% raise.
There is nothing wrong with this code from the standpoint that it does what it is intended to
do. It even remembers the current record so that it can reposition the dataset correctly when it’s
finished.
The problem with this code is that it’s slow. If you run it against the 10,000 record employee
dataset that we created in the preceding chapter, you’ll see the grid scroll through all the
records in the dataset as they are updated. (The example application at the end of this section
shows this effect.)
Advanced Client Dataset Operations
159
The solution is to disable all data-aware components (namely, the TDBGrid) attached to the
dataset before beginning this operation. To do that, you simply call the DisableControls
method before performing the lengthy operation, and then call EnableControls when you’re
finished. The following code snippet shows the updated procedure:
var
Bookmark: TBookmarkStr;
begin
ClientDataSet1.DisableControls;
try
Bookmark := ClientDataSet1.Bookmark;
try
ClientDataSet1.First;
while not ClientDataSet1.EOF do begin
if ClientDataSet1.FieldByName(‘Salary’).AsFloat < 30000.0 then begin
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Salary’).AsFloat :=
ClientDataSet1.FieldByName(‘Salary’).AsFloat * 1.05;
ClientDataSet1.Post;
end;
ClientDataSet1.Next;
end;
finally
ClientDataSet1.Bookmark := Bookmark;
end;
finally
ClientDataSet1.EnableControls;
end;
end;
As this code snippet shows, you want to wrap the code between the calls to DisableControls 4
and EnableControls in a try/finally block. If you don’t, and an exception occurs somewhere
ADVANCED CLIENT
in the code, the data-aware components cease to be updated. OPERATIONS
DATASET
Note that the calls to DisableControls and EnableControls are reference counted. If you call
DisableControls three times in your code, then you will need to call EnableControls three
times before data-aware controls are updated again.
Listing 4.3 contains the complete source code for the Updates application.
interface
Chapter 4
160
uses
Types, IdGlobal, SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QStdCtrls, DB, DBClient, QExtCtrls, QActnList, QGrids, QDBGrids,
QDBCtrls;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
pnlBottom: TPanel;
btnDisableEnable: TButton;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
btnBaseline: TButton;
procedure FormCreate(Sender: TObject);
procedure btnDisableEnableClick(Sender: TObject);
procedure btnBaselineClick(Sender: TObject);
private
{ Private declarations }
procedure PerformWork;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
procedure TfrmMain.PerformWork;
var
Bookmark: TBookmark;
begin
Bookmark := ClientDataSet1.GetBookmark;
try
ClientDataSet1.First;
while not ClientDataSet1.EOF do begin
Advanced Client Dataset Operations
161
ClientDataSet1.GotoBookmark(Bookmark);
finally
ClientDataSet1.FreeBookmark(Bookmark);
end;
end;
ADVANCED CLIENT
ClientDataSet1.DisableControls;
OPERATIONS
try
DATASET
PerformWork;
finally
ClientDataSet1.EnableControls;
end;
t2 := GetTickCount;
ShowMessage(IntToStr(t2 - t1) + ‘ ms’);
end;
end.
Chapter 4
162
The results of this test are impressive. The baseline test (which doesn’t disable controls) takes
about 19.6 seconds to run on my 1.4GHz Pentium 4. With the additional five lines to disable
and re-enable data-aware components, the code takes a mere 560ms to execute (and has no
annoying screen activity, to boot).
BLOBs
BLOBs, or Binary Large Objects, are a fundamental part of many modern database applications.
Whether you want to store images, formatted and unformatted notes, streamed components, or
any other chunk of bytes; BLOBs are an essential part of your database-programming repertoire.
In this section, I’ll show you how to effectively store BLOBs in a client dataset and how to
retrieve them later. In the pages to follow, I’ll focus specifically on notes, images, streamed
components, and generic BLOB storage.
As with other field types, you can create BLOB fields either at design time or at runtime. The
following code snippet shows how to create a BLOB field at runtime:
ClientDataSet1.FieldDefs.Add(‘Notes’, ftBlob);
Notes
One of the most common ways to use a BLOB field is to store notes, or free-format text. For
small amounts of text a string field typically suffices, but if you want to store entire memos,
you need to use a BLOB field.
Accessing a BLOB as a string is particularly easy. You can simply call the AsString method
on the field, like this:
Memo1.Text := ClientDataSet1.FieldByName(‘Notes’).AsString;
Similarly, to store the memo back to the field, you would write code like the following:
ClientDataSet1.FieldByName(‘Notes’).AsString := Memo1.Text;
Images
Another common use of BLOB fields is to store images. You might want to write a Delphi
application to catalog the pictures you’ve taken on your digital camera, or you might want to
track scanned documents in a paperless office. Either way, a BLOB field provides the necessary
support to store these images in a database.
Advanced Client Dataset Operations
163
Like formatted and unformatted memos, Delphi provides a data-aware version of an image:
TDBImage. TDBImage is lacking, however, because it only correctly stores and retrieves bitmaps
(.BMP files). A robust application should store bitmaps, JPGs, and (almost) any other image
type that the user might want to store.
There are at least three methods that you can use to store multiple image types in a dataset.
They include:
• Creating a separate field that will be used to store the image type. Use this field value to
determine how to store/load the image.
• Writing a value to the BLOB field indicating the image type, immediately followed by
the image data.
• Using a third-party imaging library to do the work for you.
The following sections discuss these options.
Presumably, the ImageType field contains the value IMAGE_NONE when the BLOB field is NULL.
To implement this method correctly, you must remember to set the ImageType field whenever
the user loads an image, and then reset it to IMAGE_NONE if the user clears the image. 4
ADVANCED CLIENT
Streaming the Image Type as Part of the BLOB Field OPERATIONS
With a little extra code, you can dispense with the additional field and store the image type in DATASET
the BLOB field along with the image itself. Figure 4.2 shows conceptually what is involved
with this method.
The following pseudo-code shows how you might implement this method:
procedure SaveImage;
begin
OpenOutputStream;
WriteImageType;
WriteImageData;
end;
Chapter 4
164
procedure LoadImage;
var
ImageType: Integer;
begin
OpenInputStream;
ImageType := ReadImageType;
case ImageType of
IMAGE_BMP: ReadBitmap;
IMAGE_JPG: ReadJPEG;
end;
end;
The sample application provided at the end of this section illustrates both of these techniques.
FIGURE 4.2
The image type immediately precedes the image data in the BLOB field.
NOTE
If you’re looking for a good imaging library, check out Skyline Tools Imaging’s
ImageLib Corporate Suite. ImageLib Corporate Suite has won numerous awards as the
best Delphi imaging library available (including Delphi Informant Magazine’s coveted
Reader’s Choice award). I use ImageLib in my own applications and recommend it
highly. You can find Skyline Tools Imaging at http://www.imagelib.com.
Streamed Data
In addition to streaming images, there are times when you might want to stream out
unstructured data to a BLOB field. Perhaps you want to store a linked list of integers in a
single field, for example. The following code snippet shows how you can use a stream to save
data to a BLOB, and then read it back in later.
procedure SaveListToBlob(List: TList);
var
Stream: TStream;
Num: Integer;
Index: Integer;
begin
Stream := ClientDataSet1.CreateBlobStream(
ClientDataSet1.FieldByName(‘DATA’), bmWrite);
try
// Write out the number of integers
Num := List.Count;
Stream.Write(Num, sizeof(Num));
ADVANCED CLIENT
Num := Integer(List[Index]);
Stream.Write(Num, sizeof(Num)); OPERATIONS
end;
DATASET
finally
Stream.Free;
end;
end;
begin
Stream := ClientDataSet1.CreateBlobStream(
ClientDataSet1.FieldByName(‘DATA’), bmRead);
try
List.Clear;
Stream.Read(Count, sizeof(Count));
for Index := 0 to Count - 1 do begin
Stream.Read(Num, sizeof(Num));
List.Add(Pointer(Num));
end;
finally
Stream.Free;
end;
end;
Note the use of Stream.Write and Stream.Read in the previous procedures. Both of these
methods take a reference to the data to be written as the first parameter, and the number of
bytes to write as the second parameter. Saving a block of data is as straightforward as making
repeated calls to TStream.Write. You must make sure to read the data in exactly the same
order as it was written in, or you will end up with an exception at best and corrupted data at
worst.
Note also the use of the TDataSet method CreateBlobStream to create a blob stream suitable
for the dataset. Many beginning Delphi database programmers attempt to call
TBlobStream.Create, like this:
var
Stream: TBlobStream;
begin
Stream := TBlobStream.Create(...);
try
// Read from or write to the stream here.
finally
Stream.Free;
end;
end;
The problem with this approach is that TBlobStream is specific to the BDE. Creating an
instance of TBlobStream will not work with non-BDE datasets, such as TClientDataSet. To
create a blob stream that will work with the current dataset, always call the dataset’s
CreateBlobStream method, like this:
Advanced Client Dataset Operations
167
var
Stream: TStream;
begin
Stream := TheDataSet.CreateBlobStream(...);
try
// Read from or write to the stream here.
finally
Stream.Free;
end;
end;
Notice that Stream is defined as a TStream, which is the ancestor class for streams. The actual
stream returned may in fact be a TBlobStream (for a BDE dataset) or another kind of stream.
Thanks to polymorphism, you can operate on the stream without knowing its exact class type.
Streamed Components
Although the concept of streaming data relies on you (the programmer) to make sure to read
and write data in the same order, by creating and streaming a component, you can let Delphi’s
built-in streaming mechanism do the work for you.
Delphi provides streaming support for components derived from TPersistent, as well as
helper functions for the TCollection family of classes.
To make streaming the list of integers that were described previously more automatic, let’s
create a component wrapper for the data.
TIntegerItem = class(TCollectionItem)
private
FNumber: Integer;
published
property Number: Integer read FNumber write FNumber;
4
ADVANCED CLIENT
end;
OPERATIONS
DATASET
TIntegerList = class(TComponent)
private
FIntList: TCollection;
published
property IntList: TCollection read FIntList;
end;
Granted, this might be overkill for a data structure as simple as a list of integers, but the same
concept works for a component containing multiple fields or complex subdata.
Chapter 4
168
NOTE
It is beyond the scope of this book to present a detailed discussion of Delphi’s streaming
support. For more information on streaming, please refer to the Component Writer’s
Guide or to the ultimate reference, the VCL/CLX source itself. If you are fortunate
enough to own a copy of Danny Thorpe’s Delphi Component Design, it also has an
informative chapter on Delphi’s streaming mechanism.
File BLOBs
Another common use of BLOB fields is storing an entire file inside a BLOB. For example, say
that you have a large number of PDF documents that you want to catalog and allow your users
to read.
By storing the PDF files in a BLOB field, you can fairly easily create an application that enables
the user to search for the PDF by category or keyword, and then view the file on his computer.
You can load an external file into a BLOB field by using the field’s LoadFromFile method, as
the following code snippet shows:
var
B: TBlobField;
begin
B := ClientDataSet1.FieldByName(‘AttachedFile’);
B.LoadFromFile(‘C:\PROPOSAL.PDF’);
end;
If you want to save the file back to disk (perhaps to load it into an application such as Acrobat
Reader), you would write the following:
var
B: TBlobField;
begin
B := ClientDataSet1.FieldByName(‘AttachedFile’);
B.SaveToFile(‘C:\TEMP.PDF’);
end;
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, QStdCtrls,
DB, QDBCtrls, QExtCtrls, QComCtrls, DBClient, JPEG;
type
TfrmMain = class(TForm)
pnlBottom: TPanel;
ClientDataSet1: TClientDataSet;
DataSource1: TDataSource;
DBNavigator1: TDBNavigator;
PageControl1: TPageControl;
tabNotes: TTabSheet;
tabImage: TTabSheet;
DBMemo1: TDBMemo;
tabAttachment: TTabSheet;
Label1: TLabel;
DBText1: TDBText;
4
ADVANCED CLIENT
ClientDataSet1Notes: TBlobField;
OPERATIONS
ClientDataSet1ImageType: TStringField; DATASET
ClientDataSet1Image: TBlobField;
ClientDataSet1Attachment: TBlobField;
btnLoadAttachment: TButton;
btnSaveAttachment: TButton;
Label2: TLabel;
DBText2: TDBText;
ClientDataSet1AttachedFile: TStringField;
Bevel1: TBevel;
Image1: TImage;
btnLoadImage: TButton;
btnClearImage: TButton;
OpenPictureDialog1: TOpenDialog;
Chapter 4
170
OpenDialog1: TOpenDialog;
SaveDialog1: TSaveDialog;
procedure FormCreate(Sender: TObject);
procedure DataSource1DataChange(Sender: TObject; Field: TField);
procedure btnLoadImageClick(Sender: TObject);
procedure btnClearImageClick(Sender: TObject);
procedure btnLoadAttachmentClick(Sender: TObject);
procedure btnSaveAttachmentClick(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
ADVANCED CLIENT
if OpenDialog1.Execute then begin
OPERATIONS
ClientDataSet1.Edit;
DATASET
ClientDataSet1AttachedFile.AsString := OpenDialog1.FileName;
ClientDataSet1Attachment.LoadFromFile(OpenDialog1.FileName);
end;
end;
end.
Chapter 4
172
The BLOBs application enables you to store a note, an image, and a file attachment in a single
record. The notes are stored by using a TDBMemo component.
I elected to create a separate image type field, which I called ImageType, to track the type of
image stored in the Image field. If ImageType is blank, the image is assumed to be NULL.
Notice the way that the image is displayed—The program handles the data source’s
DataChange event. If the Field parameter sent to the event is nil (or if it references the Image
field), the Image component loads the picture from the Image field using one method for BMP
files and another method for JPG files. Of course, a real application would recognize more
image types than just the two.
Also note the parallel between the code used to load an image and the code used to load an
attachment. Any file can be loaded into a BLOB field through the field’s LoadFromFile
method, and can be saved back to disk through the field’s SaveToFile method.
The BLOBs application doesn’t save the data to disk (although it could do so by adding a
SaveToFile method in the code), and it is rather useless. However, it serves to illustrate the
correct way (or one of the correct ways, in the case of images) to use BLOB fields in your pro-
grams.
Figure 4.3 shows this application at runtime.
FIGURE 4.3
Notes and images are a part of many modern applications.
Nested Datasets
Nested datasets are TClientDataSet’s answer to master/detail relationships. Nested datasets
physically nest the detail dataset inside the master dataset as a field. Figure 4.4 illustrates this
concept.
Advanced Client Dataset Operations
173
Customers
Name
Address
Phone
Orders
E-mail
Quantity
Orders
Description
Unit Price
FIGURE 4.4
The Orders dataset is nested inside the Customers dataset.
Datasets can be nested more than one level deep, so you can set up a grandparent/parent/child
relationship between three datasets. You can also create a parent with multiple children, or a
mixture of both (where one master contains three details and each of those contains two
details).
When you save a nested dataset to a file or stream, the entire hierarchy is saved in a single file
or stream. To save a nested dataset, call SaveToFile or SaveToStream on the master dataset,
and all nested datasets are saved automatically. LoadFromFile and LoadFromStream reload all
the data and re-establish the master/detail relationships.
To create a nested dataset at design time, first create a dataset in the usual manner. Then, add a
field to it, giving it a type of DataSet. This completes the master dataset.
To create the detail dataset, drop a second TClientDataSet on the form or data module. Create
the fields that make up the detail dataset. 4
ADVANCED CLIENT
The only remaining piece of business is to link them together. To do this, click the detail OPERATIONS
dataset and set the DataSetField property to the name of the DataSet field that you created on DATASET
the master. That’s all there is to it.
At this point, you can connect data sources and data-aware components to either dataset. As
you scroll through the master dataset, the detail dataset is automatically updated to reflect only
the detail records that are associated with the current master record.
The following example application shows how to correctly set up nested datasets in an application.
It contains customer and order datasets, where one customer can place many orders. Two
data-aware grids enable you to scroll through the customers and view the orders for each one.
Chapter 4
174
Listing 4.5 contains the complete source code for the Nested application.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QExtCtrls, QDBCtrls, QGrids, QDBGrids, DB, DBClient, QStdCtrls;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
cdsCustomer: TClientDataSet;
cdsOrder: TClientDataSet;
dsCustomer: TDataSource;
dsOrder: TDataSource;
gridCustomer: TDBGrid;
gridOrder: TDBGrid;
navCustomer: TDBNavigator;
navOrder: TDBNavigator;
cdsCustomerName: TStringField;
cdsCustomerAddress: TStringField;
cdsCustomerCity: TStringField;
cdsCustomerState: TStringField;
cdsCustomerZip: TStringField;
cdsCustomerPhone: TStringField;
cdsCustomerOrders: TDataSetField;
cdsOrderQuantity: TIntegerField;
cdsOrderDescription: TStringField;
cdsOrderUnitPrice: TFloatField;
cdsOrderTotalPrice: TFloatField;
btnLoad: TButton;
btnSave: TButton;
OpenDialog1: TOpenDialog;
SaveDialog1: TSaveDialog;
procedure cdsOrderCalcFields(DataSet: TDataSet);
procedure FormCreate(Sender: TObject);
procedure btnSaveClick(Sender: TObject);
procedure btnLoadClick(Sender: TObject);
private
{ Private declarations }
public
Advanced Client Dataset Operations
175
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
ADVANCED CLIENT
if OpenDialog1.Execute then
OPERATIONS
cdsCustomer.LoadFromFile(OpenDialog1.FileName); DATASET
end;
end.
Looking at the code, you see that there are only four methods. FormCreate creates the master
dataset. It is important to understand that this creates the detail dataset(s) also. You seldom
need to manipulate the detail dataset directly in code.
Similarly, btnSaveClick and btnLoadClick save and load the master dataset to and from disk,
which takes care of saving and loading all detail datasets, as well.
Chapter 4
176
When you run this application, you must either enter some data from scratch, or load the
datasets from a file. Accompanying the source code for this book is a previously created data
file named NESTED.CDS. You might want to load this file instead of entering customers and
orders manually.
Figure 4.5 shows the Nested application at runtime.
FIGURE 4.5
Nested datasets automatically display the detail data for the current master record.
There are a couple of interesting points about this application. First, as you move from customer
to customer in the top grid, the orders for that customer are displayed in the bottom grid. This
is done automatically, with no coding effort.
Second, if you add or modify an order, you’ll notice that the customer record enters edit mode
(as evidenced by the glyph displayed in the indicator column of the current customer record).
If you programmatically manipulate nested datasets, you want to keep the following in mind:
The master record needs to be posted after adding or modifying detail records.
Undo Support
TCustomClientDataSet and its descendents support built-in undo functionality, so you can
provide for what-if scenarios in your application. For instance, you can enable the user to
change the values of certain fields in the dataset (perhaps graphing, or otherwise displaying, an
Advanced Client Dataset Operations
177
analysis of the data). If the user doesn’t like the results, he can revert to the previous data by
undoing his changes, either one at a time or in large chunks.
Cancel
You are probably already familiar with the Cancel method, but I’ll mention it here anyway for
completeness.
The lowest level of undo support is simply discarding changes to the current record before they
have been posted. The Cancel method provides this support:
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Carter’;
// Do more stuff here, and then decide not to save changes after all.
ClientDataSet1.Cancel;
LogChanges
4
ADVANCED CLIENT
In order for the change log to be active, the dataset’s LogChanges property must be set to True OPERATIONS
(which is the default). If you don’t intend to provide undo support in your application, you can DATASET
set LogChanges to False, slightly reducing memory requirements and increasing performance.
CAUTION
When creating applications that connect a client dataset to a dataset provider (as
discussed in Chapter 7, “Dataset Providers”), you should not set LogChanges to False.
This is because setting LogChanges to False prevents you from making changes to the
client dataset and applying those changes to the underlying database.
Chapter 4
178
UndoLastChange
You can undo the most recent change to the dataset (regardless of the record it was made to)
by calling UndoLastChange.
UndoLastChange takes a single Boolean parameter (FollowChange) that indicates whether the
dataset should position itself to the record that was affected by the undo operation. If
FollowChange is True, the client dataset positions its cursor to the record that was undone or
restored. If FollowChange is False, the most recently modified record is still restored, but the
current record is not changed.
ClientDataSet1.First;
ClientDataSet1.Next;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Williams’;
ClientDataSet1.Post;
ClientDataSet1.First;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Carter’;
ClientDataSet1.Post;
ClientDataSet1.Next;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘First’).AsString := ‘Sam’;
ClientDataSet1.Post;
ClientDataSet.Last;
ClientDataSet1.UndoLastChange(True);
The preceding code snippet first modifies the second record in the dataset, then modifies the
first record in the dataset, and then modifies the second record again. Finally, it moves to the
end of the dataset. The call to UndoLastChange undoes only the second change to the second
record, and repositions the dataset at the second record (because True was passed to
UndoLastChange).
If you were to issue a second call to UndoLastChange, the modification to the first record in the
dataset would be undone. A third call to UndoLastChange would undo the first modification to
the second record.
RevertRecord
RevertRecord undoes all changes to the current record in the dataset. Modifying the preceding
code snippet slightly, we get the following:
Advanced Client Dataset Operations
179
ClientDataSet1.First;
ClientDataSet1.Next;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Williams’;
ClientDataSet1.Post;
ClientDataSet1.First;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Carter’;
ClientDataSet1.Post;
ClientDataSet1.Next;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘First’).AsString := ‘Sam’;
ClientDataSet1.Post;
ClientDataSet.First;
ClientDataSet.Next;
ClientDataSet1.RevertRecord;
This code makes the same three changes that the previous code snippet made. It then moves
off the second record and back onto it. (This is just for the purpose of demonstration—You
don’t need to do it.) Finally, the call to RevertRecord undoes both changes that were made to
the second record, but it leaves the change to the first record intact.
SavePoint
SavePoint provides a means of establishing a baseline for database operations, and then
returning to that baseline at a later point in time.
For example, assume that the user made a change to a dataset. He then wants to experiment 4
ADVANCED CLIENT
with some other changes, but isn’t sure that he wants to save the results. After the first change,
OPERATIONS
you could retrieve the current value of SavePoint, like this: DATASET
var
Baseline: Integer;
begin
Baseline := ClientDataSet.SavePoint;
Later, after making modifications to the database, you can return to the baseline by setting the
SavePoint property:
ClientDataSet.SavePoint := Baseline;
Setting SavePoint discards all changes made to the dataset after the baseline was established.
Chapter 4
180
ClientDataSet1.First;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘Last’).AsString := ‘Carter’; // Change 2
ClientDataSet1.Post;
ClientDataSet1.Next;
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘First’).AsString := ‘Sam’; // Change 3
ClientDataSet1.Post;
In this code snippet, two changes are made, and then a baseline is established. Next, a third
change is made, and then the third and second changes are undone. Finally, the code attempts
to revert to the save point. Because the change log has been reversed past the point of the save
point, Delphi raises an exception.
Advanced Client Dataset Operations
181
CancelUpdates
The final level of undo support is undoing all changes in the change log. To do this, simply
call CancelUpdates, like this:
ClientDataSet1.CancelUpdates;
CancelUpdates discards all changes made to all records in the dataset by clearing the change
log.
ChangeCount
You can determine how many changes were made to the dataset by looking at the ChangeCount
property:
if ClientDataSet1.ChangeCount > 0 then
ShowMessage(‘It is okay to call UndoLastChange’);
MergeChangeLog
At some point in your application, you might want to merge changes in with the data to
commit any modifications that were made to the dataset. To do this, call MergeChangeLog.
MergeChangeLog takes no parameters.
NOTE
When a client dataset is connected to a provider, you seldom call MergeChangeLog
directly. Instead, you call ApplyUpdates, which makes a call to MergeChangeLog after
the changes have been applied to the underlying dataset.
StatusFilter 4
ADVANCED CLIENT
The StatusFilter property provides for a type of filter on the dataset, but I didn’t discuss it
OPERATIONS
in the “Ranges and Filters” section because it relates directly to the change log. DATASET
As records are added, modified, or deleted in a client dataset, they are tagged with a status.
That status can be one (or more) of the values shown in Table 4.4.
Value Description
usUnmodified The record has not been modified in any way.
usInserted The record has been newly inserted into the dataset.
usModified The record was modified.
usDeleted The record has been deleted.
Chapter 4
182
If you would like to view only those records that have been added to the dataset, you can set
StatusFilter to usInserted. To view only added or modified records, set StatusFilter to
usInserted, usModified.
If ClientDataSet1’s change log is empty, this statement causes a Delta is empty exception
to be raised. So, you should always check the ChangeCount property before attempting to do
this.
The following sample application demonstrates the techniques discussed in this section. Listing
4.7 contains the source code for the main form of the application.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QStdCtrls, DB, DBClient, QExtCtrls, QActnList, QGrids, QDBGrids;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
pnlClient: TPanel;
DBGrid1: TDBGrid;
ClientDataSet1: TClientDataSet;
pnlBottom: TPanel;
btnRemoveFilter: TButton;
btnFilter: TButton;
btnUndo: TButton;
btnRevertRecord: TButton;
btnCancelUpdates: TButton;
btnSetSavepoint: TButton;
btnGotoSavepoint: TButton;
btnViewChangeLog: TButton;
procedure FormCreate(Sender: TObject);
procedure btnRemoveFilterClick(Sender: TObject);
procedure btnFilterClick(Sender: TObject);
Advanced Client Dataset Operations
183
var
frmMain: TfrmMain;
implementation
uses ChangeLogForm;
{$R *.xfm}
ADVANCED CLIENT
ClientDataSet1.StatusFilter := [usInserted];
OPERATIONS
end;
DATASET
procedure TfrmMain.btnRemoveFilterClick(Sender: TObject);
begin
ClientDataSet1.StatusFilter := [];
end;
end.
Listing 4.8 shows the source code for the form that displays the change log.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QExtCtrls, QStdCtrls, QGrids, QDBGrids, DB, DBClient;
Advanced Client Dataset Operations
185
type
TfrmChangeLog = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
ClientDataSet1: TClientDataSet;
DataSource1: TDataSource;
DBGrid1: TDBGrid;
btnClose: TButton;
Label1: TLabel;
procedure ClientDataSet1AfterScroll(DataSet: TDataSet);
private
{ Private declarations }
public
{ Public declarations }
constructor Create(AOwner: TComponent;
ADataSet: TCustomClientDataSet); reintroduce;
end;
implementation
{$R *.xfm}
{ TfrmChangeLog }
ClientDataSet1.Data := ADataSet.Delta;
4
ADVANCED CLIENT
end;
OPERATIONS
DATASET
procedure TfrmChangeLog.ClientDataSet1AfterScroll(DataSet: TDataSet);
begin
case ClientDataSet1.UpdateStatus of
usUnmodified: Label1.Caption := ‘Unmodified’;
usModified: Label1.Caption := ‘Modified’;
usInserted: Label1.Caption := ‘Inserted’;
usDeleted: Label1.Caption := ‘Deleted’;
end;
end;
end.
Figure 4.6 shows the ChangeLog application at runtime as it views the change log for the
EMPLOYEE.CDS dataset.
Chapter 4
186
FIGURE 4.6
The ChangeLog application shows how modifications to a dataset are efficiently stored.
Note the four lines in the change log. The first line shows the data for a newly added
employee. The second line shows the data for a deleted employee. The third and fourth lines
show modifications to an existing employee. The third line contains the employee data before
any modifications were made, but the fourth line contains data for only those fields that were
modified.
To clone a dataset, create a second client dataset, and then call the second dataset’s
CloneCursor method, like this:
var
cdsClone: TClientDataSet;
begin
cdsClone := TClientDataSet.Create(nil);
try
cdsClone.CloneCursor(ClientDataSet1, False, False);
This code snippet creates a clone of ClientDataSet1, performs some operation(s) on the
clone, and then frees the clone. Any Insert, Edit, or Delete operations performed on the
clone are automatically reflected in ClientDataSet1.
CloneCursor is defined like this:
procedure CloneCursor(Source: TCustomClientDataSet; Reset: Boolean;
KeepSettings: Boolean = False); virtual;
Source refers to the client dataset that you want to clone. You can’t clone a nonclient dataset,
such as a BDE dataset or a dbExpress dataset.
Reset and KeepSettings work hand in hand, and determine how the clone handles the
following attributes:
• Filter, Filtered, FilterOptions, OnFilterRecord
4
• IndexName
ADVANCED CLIENT
• MasterSource, MasterFields OPERATIONS
DATASET
• ReadOnly
• RemoteServer, ProviderName
If Reset and KeepSettings are set to False, all the previous properties are copied from the
original dataset to the clone. If Reset is False and KeepSettings is True, the previous
properties are not changed for the clone. If Reset is True (regardless of the value of
KeepSettings), the previous properties are cleared on the clone. Table 4.5 depicts this
relationship.
Chapter 4
188
If you want the clone to copy some of the listed properties from the original dataset, but not to
copy others, you have to write some code. One way to handle this situation is to set both Reset
and KeepSettings to False copying all the properties listed previously from the original
dataset to the clone. Then, reset the clone’s properties that were overwritten by the original
dataset. Alternately, you could set Reset and KeepSettings to True, and then set the appropriate
properties on the clone.
NOTE
After cloning a client dataset, the clone does not contain any persistent fields types.
This means that you’ll typically use FieldByName to access fields in the clone. Also, the
clone does not inherit any standard calculated fields (internal calculated fields are
inherited) from the original dataset, so be careful not to try accessing any calculated
fields in the clone.
The following example program shows how to effectively use a cloned dataset in the situations
listed at the beginning of this section. Listing 4.9 contains the complete source code for the
Clone application.
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms,
QDialogs, QGrids, QDBGrids, DB, DBClient, QExtCtrls, QStdCtrls;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
ClientDataSet1: TClientDataSet;
Advanced Client Dataset Operations
189
DataSource1: TDataSource;
DBGrid1: TDBGrid;
btnUpdate: TButton;
btnInsert: TButton;
btnRange: TButton;
btnInsert2: TButton;
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure btnUpdateClick(Sender: TObject);
procedure btnInsertClick(Sender: TObject);
procedure btnRangeClick(Sender: TObject);
procedure btnInsert2Click(Sender: TObject);
private
{ Private declarations }
FCloneDS: TClientDataSet;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
ADVANCED CLIENT
ClientDataSet1.AddIndex(‘byID’, ‘ID’, [ixPrimary, ixUnique]);
OPERATIONS
ClientDataSet1.IndexName := ‘byID’;
DATASET
FCloneDS := TClientDataSet.Create(nil);
FCloneDS.CloneCursor(ClientDataSet1, False, False);
end;
FCloneDS.Next;
end;
finally
Screen.Cursor := crDefault;
end;
end;
FCloneDS.Append;
FCloneDS.FieldByName(‘ID’).AsInteger := 99999;
FCloneDS.FieldByName(‘Name’).AsString := ‘Eric Harmon’;
FCloneDS.FieldByName(‘Birthday’).AsString := ‘1/1/1967’;
FCloneDS.FieldByName(‘Salary’).AsFloat := 1.00;
end;
try
FCloneDS.Post;
ClientDataSet1.GotoCurrent(FCloneDS);
except
FCloneDS.Cancel;
raise;
end;
end;
FCloneDS.Next;
end;
finally
FCloneDS.CancelRange;
end;
finally
Screen.Cursor := crDefault;
end;
end;
end.
ADVANCED CLIENT
OPERATIONS
DATASET
FIGURE 4.7
Cloned datasets can be extremely useful for inserting and updating records.
Looking at this code, you might notice several things. One of them is that I’ve created the
cloned dataset in the FormCreate method and destroyed it in the FormDestroy method. I did
this for simplification. In a real application, you usually create a clone in the method(s) in
which it is needed, and immediately destroy it afterward. By creating and destroying it once in
this sample program, I saved a few lines of code.
Chapter 4
192
The Update button runs through the entire dataset and gives all employees a 10% raise. Notice
that even though the code doesn’t call DisableControls (discussed near the beginning of this
chapter), the grid still doesn’t scroll as the dataset is traversed. That’s because the grid is
connected to ClientDataSet1, and the update is performed on the clone dataset.
The Insert (Part 1) and Insert (Part 2) buttons perform two halves of an insert operation on the
clone. The first button appends a new record to the dataset and fills in the data. The second
button posts the new record to the dataset, and then calls the GotoCurrent method on the original
dataset (synchronizing the original dataset with the clone). GotoCurrent makes the original
dataset jump to the current record of the clone dataset.
The reason for separating the Insert operation into two buttons is so that you can easily see
what happens during an insert. Click Insert (Part 1). Now scroll down to the bottom of the grid.
You will not see a line in the grid for employee 99999—the newly inserted, but not-yet-posted
record. When you click Insert (Part 2), the new record appears in the grid. In contrast, if you
change the code in the Insert button event handlers to use ClientDataSet1, instead of
FCloneDS, you will see the new record appear in the grid before it is actually posted.
Finally, the Range button operates in much the same way as the Update button: It applies a
range to the dataset, and then sets the salary for all employees in that range to $50,000. The
grid is not updated to reflect the range because the range is applied only to the clone dataset
and not to ClientDataSet1.
Maintained Aggregates
Records in a dataset often aren’t completely isolated from one another. Many times, you want
to obtain the sum or the average of either an entire column, or some subset of that column. For
example, you might want to calculate the average salary of all employees in the Sales department,
or you might need to retrieve the number of employees whose last name is Jones.
If you’re using an SQL-based database, you can issue SQL statements to calculate these values.
For example,
SELECT AVG(SALARY) FROM EMPLOYEE WHERE DEPARTMENT = ‘SALES’;
However, you can’t execute SQL statements directly against a client dataset.
NOTE
If you connect a client dataset to a dbExpress dataset through a database provider,
you can then send SQL statements to the database backend and retrieve the results in
the client dataset. This technique is discussed in Chapter 7.
Advanced Client Dataset Operations
193
Instead, client datasets support a powerful feature called maintained aggregates. Maintained
aggregates automatically calculate the sum, average, count, minimum value, or maximum
value for the entire dataset (or for a group or records).
Persistent Aggregates
You create a persistent aggregate in much the same way that you create a data, calculated, or
lookup field.
1. Right-click the client dataset in the form editor and select Field Editor… from the
pop-up menu.
2. Press Ins to create a new field.
3. Enter the field name and field type in the New Field dialog. The field type should be
Aggregate for maintained aggregates.
4. Select the Aggregate radio button in the Field type group box.
5. Click OK to create the aggregate.
Figure 4.8 shows the field editor after adding an aggregated field named AvgSalary.
ADVANCED CLIENT
OPERATIONS
DATASET
FIGURE 4.8
The field editor shows aggregates in a separate section.
After the aggregate is created, you need to set some additional properties (such as the expression
to aggregate on, and whether or not the aggregate is active).
1. Click the aggregate field in the field editor.
2. In the Object Inspector, enter the aggregate expression in the Expression property.
Aggregate expressions are discussed in the section titled “Aggregate Expressions.” For
now, you can use Avg(Salary).
Chapter 4
194
3. Set the Active property to True. By default, aggregates are not active, which means that
you aren’t able to access them. Set AggregatesActive to True to activate aggregate
fields.
Creating a persistent aggregate automatically creates a component of type TAggregateField,
which you can use to reference the aggregate value. Unlike most fields, however, you don’t use
the AsFloat property to obtain an aggregate’s value. Instead, you use the Value property, like
this:
ShowMessage(‘The average salary is ‘ + ClientDataSet1AvgSalary.Value);
Because Value is a variant, you can reference it as though it were either a string or a floating-
point number. Therefore, the following code is also correct:
var
AvgRaisedSalary: Double
begin
AvgRaisedSalary := ClientDataSet1AvgSalary.Value * 1.05;
end;
Nonpersistent Aggregates
To create a nonpersistent aggregate at design time, click the client dataset in the form editor,
and then double-click the Aggregates property in the Object Inspector to display the aggregate
editor.
The aggregate editor looks and acts a lot like the field editor. Click the Add New button on the
toolbar (or press Ins) to create a new aggregate.
Again, you need to set some additional properties (such as the expression to aggregate on, and
whether or not the aggregate is active).
1. Click the aggregate in the aggregate editor.
2. In the Object Inspector, enter a name for the aggregate, such as AvgSalary.
3. Type an aggregate expression into the Expression property, such as Avg(Salary).
4. Set the Active property to True.
5. Set AggregatesActive to True.
Unlike in the previous section, Delphi does not create a component for an aggregate created in
this manner. Instead, you access the aggregate through the dataset’s Aggregates property, like
this:
ShowMessage(‘The average salary is ‘ +
ClientDataSet1.Aggregates.Find(‘AvgSalary’).Value);
Advanced Client Dataset Operations
195
Alternately, if you know the index of the aggregate, you can access it directly:
ShowMessage(‘The average salary is ‘ + ClientDataSet1.Aggregates[0].Value);
Aggregate Expressions
In the previous code snippets, I used an expression of Avg(Salary). As you might guess, this
expression calculates the average value of the Salary field.
Delphi supports the aggregate types listed in Table 4.6.
ADVANCED CLIENT
Count Calculates the number of values for a field that are not blank.
OPERATIONS
Min Calculates the minimum value of a field. DATASET
Max Calculates the maximum value of a field.
Sum and Avg can only be used with numeric field types; but Count, Min, and Max can be used
with numbers, strings, or date values.
Aggregate expressions do not have to be simple expressions, such as Avg(Salary). They can
include multiple functions, such as Sum(SalesPrice) - Sum(NetCost). However, you can’t
nest functions. Avg(Sum(SalesPrice)) is not a valid aggregate expression.
The Delphi help topic, “Specifying Aggregates,” provides additional examples of both valid
and invalid aggregate expressions.
Chapter 4
196
7. Drop a TDataSource, TDBGrid, and TLabel on the main form and connect the data source
and grid to the client dataset.
8. In the data source’s OnDataChange event, write the following code:
Label1.Caption := ClientDataSet1NumSameBirthday.Value;
Now we have our starting application. If you run it, you should see the value 10000 appear in
the label (indicating that there are 10,000 records with a non-NULL birthday in the dataset).
Now that we’ve established our baseline, let’s change the aggregate so that it calculates the
number of employees who have the same birthday.
To do this, go back into the field editor and click the NumSameBirthday aggregate field. In the
Object Inspector, set the IndexName to byBirthday. This tells the aggregate field to calculate
its value using the byBirthday index. Next, set the GroupingLevel property to 1.
(GroupingLevel is a one-based value that tells Delphi which portion of the index to use when
calculating the aggregate value.)
For example, say that you have an index on the fields LastName;FirstName. If GroupingLevel
is set to 1, only the first field in the index is used to calculate the aggregate. For an expression
of Avg(Salary), the aggregate calculates the average salary for all employees having the same
last name as the current record.
Advanced Client Dataset Operations
197
If you set GroupingLevel to 2, the aggregate calculates the average salary for all employees
having the same last name and the same first name as the current employee.
GetGroupState
You can determine the relative position of a record within an aggregate by calling the dataset’s
GetGroupState method. GetGroupState returns a value of gbFirst, gbMiddle, or gbLast
(depending on whether the current record is the first record in the group, the last record in the
group, or any other record in the group).
Rather than presenting a sample aggregate application here, I’ll refer you to the Aggregate
demo in Delphi’s DEMOS\MIDAS\AGGREGATE directory.
Miscellaneous Properties
This section lists several additional properties of the client dataset that don’t logically fall into
one of the previously discussed categories. 4
ADVANCED CLIENT
Constraints OPERATIONS
DATASET
Constraints provide a way of validating a record’s data before posting. Constraints are most
useful when the validation relies on a relationship between two or more fields in the record.
For example, a record’s data might (nonsensically) be determined invalid if the Salary field is
less than 1000 times the employee’s age. In other words, a 30-year-old employee must earn at
least $30,000.
Constraints are visually similar to filters. The constraint that I just mentioned looks like this:
Salary >= Age * 1000
Back in the Object Inspector, enter the constraint into the CustomConstraint property. In the
ErrorMessage property, type the message that you’d like to be displayed when the constraint is
not met—for example, “The salary must be at least 1000 times the employee’s age.”
Figure 4.9 shows a screen capture of the Object Inspector and the constraint editor after adding
a constraint.
FIGURE 4.9
Setting up a constraint.
You are not limited to a single constraint for a dataset. You can add as many constraints as are
necessary.
When the record is posted to the dataset, Delphi checks all the constraints imposed on the
dataset. If one of the constraints fails, the message specified in the ErrorMessage property is
displayed, and the post is aborted.
DisableStringTrim
Normally, when records are posted to a dataset, any trailing spaces in a string are automatically
removed. For example, if a user types John (note the two trailing spaces) in a data-aware
edit control, only four characters are actually written to the underlying field in the dataset
because the dataset automatically removes the additional two spaces.
Client datasets globally trim trailing spaces from string fields when the DisableStringTrim
property is set to False (which is the default). However, if you want to retain trailing spaces,
you can set DisableStringTrim to True.
Advanced Client Dataset Operations
199
DisableStringTrim is a global property, in that it affects all the string fields in the dataset. It
doesn’t allow you to trim trailing spaces from the FirstName field, and still retain trailing
spaces for the LastName field. If you want to retain trailing spaces for some fields and remove
them for others, you need to set DisableStringTrim to True, and then remove trailing spaces
manually from the appropriate fields (perhaps in the dataset’s BeforePost event handler).
ReadOnly
By default, client datasets are read/write datasets. You can make a client dataset read-only
(if the underlying data is stored on a CD-ROM drive, for instance) by setting the dataset’s
ReadOnly property to True.
Summary
In addition to the basic functionality presented in the preceding chapter, TClientDataSet
supports a number of advanced operations. In this chapter, you learned the following:
• Datasets provide a number of events that you can hook into and be notified when certain
operations occur. In addition, you can raise an exception during the BeforeXxx events to
prevent the operations from occurring.
• You can increase performance dramatically by disabling data-aware controls during
lengthy processes.
• Delphi datasets provide support for BLOBs, which can be used to store notes, images,
and other unformatted data.
• Nested datasets provide simplified master/detail support in client datasets.
• TClientDataSet’s undo support enables you to perform what-if scenarios in your
applications. 4
ADVANCED CLIENT
• By cloning a client dataset, you can perform operations on a clone of the data without
OPERATIONS
disturbing the settings of the original dataset. DATASET
• Maintained aggregates support the automatic calculation of sums, minimums, maximums,
counts, and averages for groups of records or for the entire dataset.
The following chapter begins a two-chapter introduction to data-aware components.
Data-Aware Components CHAPTER
5
IN THIS CHAPTER
• What Are Data-Aware Components? 202
• TDataSource 204
• TDBNavigator 223
The preceding two chapters concentrated on client datasets, Delphi’s flexible in-memory
datasets. This chapter introduces the concept of data-aware components, which are ready-made
components that know how to display and edit information stored in a database.
Data-aware components can be used with differing datasets, including BDE, ADO, IBX, and
third-party datasets. However, this chapter shows how to use them with client datasets because
that is how they are used when dbExpress is the underlying data access technology.
As you can see in Table 5.1, the similarities between the standard components and the data-
aware components are self-explanatory (with the possible exception of the TDBLookupComboBox
and TDBLookupListBox components).
Except for TDataSource, you’ll find all the components listed in Table 5.1 on the Data
Controls tab of the component palette. TDataSource can be found on the Data Access tab.
All data-aware components discussed in this chapter provide two properties that you must set.
The DataSource property references the TDataSource component that provides the link
between the component and the dataset. The DataField property determines from which field
in the dataset the data-aware component retrieves its data.
Later sections in this chapter discuss each of these components, with the exception of TDBGrid.
Because the grid is such an involved component, I’ll spend the following chapter investigating
it. Rather than providing numerous small sample applications throughout this chapter, I’ll defer
an example until the end.
For the most part, the data-aware components mirror their non–data-aware counterparts, so I
have not spent a lot of time and space here discussing each of their properties, events, and
methods. Instead, I’ve concentrated on issues that are specific to the data-aware version of the
component. If you need basic information about the component’s properties or methods, please
refer to either the online help or to one of the excellent general-purpose Delphi books
available.
NOTE
Some Delphi programmers shy away from data-aware components—mostly because
they are aware of the implementation problems with data-aware components in
Visual Basic. They might also shy away because data-aware components received a 5
bad reputation in Delphi’s early days. Rest assured that data-aware components
DATA-AWARE
exhibit good performance characteristics under Delphi, especially when used with
COMPONENTS
TDataSource
As Table 5.1 indicates, TDataSource provides a conduit between a dataset and one or more
data-aware controls that are connected to it. You cannot connect a data-aware component
directly to a dataset. Instead, you connect a TDataSource to the dataset, and then connect one
or more data-aware components to the data source (as Figure 5.1 illustrates).
FIGURE 5.1
Relationship between datasets, data sources, and data-aware components.
TDataSource is a rather simple component, publishing just three events and three properties, in
addition to the Name and Tag properties common to all components. Table 5.2 lists the pub-
lished properties and Table 5.3 lists the data source’s events. OnDataChange and
OnStateChange (the most commonly used of the events) are applied in Listing 5.4, later in this
chapter.
Property Description
AutoEdit When True, the underlying dataset is automatically placed into edit
mode as soon as the user starts to type into a data-aware component
that is connected to this data source. When False, you must specifi-
cally call the dataset’s Edit method before the user can type into
any of the connected data-aware controls.
DataSet Provides a link to the dataset from which the data-aware compo-
nents retrieve data.
Enabled When Enabled is True, the data-aware components connected to
this data source display the data contained in the dataset. When it’s
False, data-aware controls are blank.
Data-Aware Components
205
Event Description
OnDataChange Fires when the dataset’s current record data is changed; either
because the dataset’s cursor is moved to a new record, or because
one of the fields is modified.
OnStateChange Fires when the underlying dataset’s State property changes. For
example, when the dataset transitions from browse mode to edit
mode, or from insert mode to browse mode.
OnUpdateData Fires immediately before the underlying dataset posts changes to the
database.
It is easy to forget about TDataSource when writing database applications. After dropping the
data source on a form and connecting the data-aware components to it, the data source often
seems to serve no useful purpose. However, the three events listed in Table 5.3 are extremely
useful in a variety of situations. An example of their usefulness is shown in the sample applica-
tion at the end of this chapter.
This is not the correct way, however. What you should do is set the underlying field value to
John, like this:
5
DATA-AWARE
COMPONENTS
ClientDataSet1.Edit;
ClientDataSet1FirstName.AsString := ‘John’;
Chapter 5
206
If you are not using persistent field objects, you would do this instead:
ClientDataSet1.Edit;
ClientDataSet1.FieldByName(‘FirstName’).AsString := ‘John’;
The important thing to remember is to call the dataset’s Edit method before attempting to set
the field value. If the dataset is already in edit or insert mode, the redundant call to Edit does-
n’t have any adverse effects. If for some reason the dataset can’t be edited (for example, if the
dataset’s ReadOnly property is set to True), the call to Edit raises an exception, which you
should be prepared to handle gracefully.
Numeric Fields
When you connect a data-aware component to a numeric field, the data that is displayed in a
component is formatted according to the underlying field’s DisplayFormat property.
DisplayFormat is a string property that consists of up to three parts, separated by semicolons,
in the following format:
<Positive>;<Negative>;<Zero>
The different sections of the string determine how the value is displayed when it is positive,
negative, or zero (respectively). Null values are always displayed as a blank.
Table 5.4 lists the characters that can be used within the DisplayFormat string.
Character Description
# Digit placeholder. If the formatted value does not require a digit at that
position, the position is not filled. For example, the value 1.2 formatted
with a DisplayFormat of ###.## yields 1.2, with no leading or trailing
spaces.
0 Digit placeholder. If the formatted value does not require a digit at that
position, the position is filled with a 0. For example, the value 1.2 format-
ted with a DisplayFormat of 000.00 yields 001.20.
. Decimal point. Determines where the radix point occurs in the output
string. The decimal point is replaced by the character stored in the
DecimalSeparator global variable.
, Thousands separator. The occurrence of a comma in the DisplayFormat
indicates that the value should be formatted using thousands separators.
The comma does not occupy a position in the output string—it only
serves as an indication that thousands separators are needed. At runtime,
the comma is replaced by the character stored in the ThousandSeparator
global variable.
E+/– Scientific notation. If E+, E–, e+, or e– is present in the DisplayFormat,
the value is formatted using scientific notation. E+ indicates that all expo-
nents should be preceded by a sign. E– indicates that only negative expo-
nents should be preceded by a sign. The E+ or E– is followed by one to
four zeros, specifying the minimum number of digits to include in the 5
exponent.
DATA-AWARE
the string.
‘ or “ Literal. Characters enclosed in single or double quotes are copied literally
to the output string, and are not interpreted as formatting characters.
Chapter 5
208
If you only specify a single substring in DisplayFormat, it is used to format all numbers. To
use a different output format for negative or zero values, separate the specifiers with semi-
colons, like this:
$,0.00;($,0.00);<zero>
This DisplayFormat string formats positive numbers as dollars and cents, negative numbers
within parentheses, and zero values as the string <zero>.
You can omit a portion of the string by simply leaving its specifier empty. In this case, the pos-
itive format is used instead. For example:
$,0.00;;<zero>
In this case, positive and negative values are both formatted using $,0.00. Zero values are dis-
played as <zero>.
If the DisplayFormat property is left completely blank, the value is displayed using general
floating-point output with 15 significant digits.
Data-Aware Components
209
By default, the same format is used when editing a field’s value. You can set a different format
to use when editing by setting the field’s EditFormat property in addition to, or instead of the
DisplayFormat property. EditFormat works the same as DisplayFormat: It contains a semi-
colon-delimited set of formats to use when displaying positive, negative, and zero values.
For example, suppose that you have a floating-point field that you want displayed as 15.25%,
but when editing, you don’t want the percent sign displayed. You would set DisplayFormat to
#0.00%, and EditFormat to #0.00.
String Fields
String fields do not have separate DisplayFormat and EditFormat properties. Instead, they
have an EditMask property, which is used for both displaying and editing a field’s value.
EditMask holds a Paradox-style edit mask that determines how the string is both displayed and
edited.
Like the DisplayFormat and EditFormat properties, EditMask consists of three parts, sepa-
rated by semicolons. The first part is the mask to use when formatting the string value. The
second part contains a 0 to indicate that literals should not be saved as part of the string value.
Any other character in the second part of the string indicates that literals should be saved as
part of the string value. The third part represents the character that’s displayed to represent
blanks, or characters that have not yet been entered.
For example, the following EditMask accepts a U.S. Social Security number, storing the
hyphens in the underlying field and displaying underscores where numbers are to be entered.
000-000-0000;1;_
Table 5.6 shows the valid EditMask specifiers for string fields.
Character Description
L Requires an alphabetic character.
l Allows an alphabetic character, but does not require it.
A Requires an alphanumeric character.
a Allows an alphanumeric character, but does not require it.
C Requires a character.
5
c Allows a character, but does not require it.
DATA-AWARE
0
9 Allows a numeric character, but does not require it.
# Allows a numeric character, or a plus or minus sign, but does not
require it.
Chapter 5
210
NOTE
Each character in the mask represents one byte in the string—not one character. For
that reason, when working with multibyte character sets, each character in the string
is represented by two characters in the EditMask. For example, AA and LL each repre-
sent a single multibyte character. When inserting a literal into a mask, only single-
byte literal characters can be entered.
NOTE
The built-in Delphi edit masks leave something to be desired if you’re wanting to use
anything more than a simple edit mask. The standard data-aware components don’t
validate complex masks well (such as phone numbers, Social Security numbers, and
the like). For this reason, you might want to consider a third-party library to assist
you with data entry and validation. I use Orpheus, from TurboPower Software
Company. You can find TurboPower’s Web site at http://www.turbopower.com.
TDBText is the simplest of all the data-aware components. It is a display-only component, simi-
lar to the standard TLabel. To use it, drop a TDBText component on a form, and set the
DataSource and DataField properties. The data is displayed according to the output format
discussed in the preceding section.
Chapter 5
212
TDBEdit
TDBEdit corresponds to the standard TEdit component. It’s used to display and edit numeric,
string, or data/time data contained in a dataset.
Data is displayed according to the underlying field’s DisplayFormat or EditMask property, and
is edited according to the EditFormat or EditMask property (depending on the field type).
TDBMemo
TDBMemo is similar in concept to TDBEdit, except that it can display and edit multiline text
fields, such as unformatted notes. A TDBMemo is usually connected to a CLOB (Character Large
Object) field, although you can also use it to edit string fields.
TDBCheckBox
TDBCheckBox is used like a standard TCheckBox—to display and enter yes/no or true/false val-
ues. TDBCheckBox can be connected to a Logical or a Yes/No field in desktop databases, such
as Paradox or Access. Most SQL databases, however, don’t directly support these field types.
In these cases, you connect the TDBCheckBox to a string field, which is frequently a single char-
acter.
To define the relationship between checked/unchecked and the underlying field data, you set
the component’s ValueChecked and ValueUnchecked properties. ValueChecked refers to the
value of the field when the checkbox is marked. ValueUnchecked determines the value of the
field when the checkbox is not marked. When the form containing the TDBCheckBox is first dis-
played, and the underlying field contains a value that does not equal either of these two proper-
ties, the checkbox is initially grayed out.
By default, the values of these two properties are true and false, respectively. In my own appli-
cations, I use a single character field (VARCHAR(1)) for Boolean field types. I use T for true and
F for false. Because of this, whenever I drop a TDBCheckBox on a form, I set ValueChecked to T
and ValueUnchecked to F.
NOTE
If you decide to always use a single character field for Boolean field types, you might
want to consider creating a simple component derived from TDBCheckBox that sets
ValueChecked to T and ValueUnchecked to F (by default). That way, you don’t have to
manually set these properties every time you use the component.
In addition, you should probably consider creating a domain in the database to spec-
ify character fields. This is the domain that I create for my own InterBase databases:
CREATE DOMAIN DOM_BOOLEAN AS VARCHAR(1)
DEFAULT ‘F’ NOT NULL CHECK (VALUE IN (‘F’, ‘T’));
Data-Aware Components
213
TDBRadioGroup
TDBRadioGroup is used in cases when you want the user to select one option from a short list
of options. By default, the value of the selected item is stored in the underlying dataset field,
which means you typically connect the TDBRadioGroup component to a string field.
In my applications, I’ve found that I most often want to store the index of the selected item in
an Integer field. This is straightforward to accomplish if you make use of the component’s
Values property. Values is a string list that corresponds to the Items property in the following
manner:
• If Values is empty, the strings contained in the Items property are stored in the underly-
ing dataset.
• If Values is not empty, it should contain the same number of string values as the Items
property. When an item is selected in the radio group, the corresponding value in the
Values property is stored in the dataset.
Using the second rule, you can store a sequential list of numbers in the Values property and
connect the component to an Integer field. Delphi is then smart enough to store the numeric
representation of the selected item in the dataset. Figure 5.2 shows this concept. When the user
selects Tuesday from the list, the number three is stored in the associated field.
Items Value
Sunday 1
Monday 2
Tuesday 3
Wednesday 4
Thursday 5
Friday 6
Saturday 7
Tuesday 3
FIGURE 5.2
Relationship between TDBRadioGroup’s Items and Values properties.
5
TDBComboBox
DATA-AWARE
COMPONENTS
TDBComboBox works similarly to TDBRadioGroup because it enables the user to select an item
from a list and store it in a dataset. Unfortunately, it doesn’t support the Values property, so
you can’t use it to store the index of the selected item in a dataset. In my applications, this is
often a severe limitation, so I’ve created a descendent component named TETHDBComboBox that
supports assigning a value to each string contained in the Items property.
Chapter 5
214
NOTE
The Values property only comes into play when the component’s Style property is
set to csDropDownList, csOwnerDrawFixed, or csOwnerDrawVariable. If the style is set
to csDropDown or csSimple, the Values property is ignored because, in either case,
the user can enter any value in the edit portion of the combo box.
Listing 5.1 contains the source code for the TETHDBComboBox component.
interface
uses
Windows, Messages, SysUtils, Classes, Controls, StdCtrls, DBCtrls;
type
TETHDBComboBox = class(TDBComboBox)
private
{ Private declarations }
FDataLink: TFieldDataLink;
FValues: TStrings;
procedure DataChange(Sender: TObject);
procedure UpdateData(Sender: TObject);
function GetComboValue(Index: Integer): string;
function GetComboText: string;
procedure SetComboText(const Value: string);
procedure SetValues(const Value: TStrings);
protected
{ Protected declarations }
procedure CreateWnd; override;
public
{ Public declarations }
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
published
{ Published declarations }
property Values: TStrings read FValues write SetValues;
end;
Data-Aware Components
215
implementation
procedure Register;
begin
RegisterComponents(‘ETH’, [TETHDBComboBox]);
end;
{ TETHDBComboBox }
inherited Create(AOwner);
end;
destructor TETHDBComboBox.Destroy;
begin
FValues.Free;
inherited;
end;
procedure TETHDBComboBox.CreateWnd;
begin
inherited;
begin
if (Index < FValues.Count) and (FValues[Index] <> ‘’) then
Result := FValues[Index]
else if Index < Items.Count then
Result := Items[Index]
Chapter 5
216
try
if Value = ‘’ then
I := -1
else begin
I := -1;
for Index := 0 to Items.Count - 1 do
if Value = GetComboValue(Index) then begin
I := Index;
Break;
end;
end;
ItemIndex := I;
finally
if Redraw then begin
SendMessage(Handle, WM_SETREDRAW, 1, 0);
Invalidate;
end;
end;
Data-Aware Components
217
end.
Listing 5.1 contains some code that you might not be familiar with, so I’ll examine some of
the individual routines in more detail.
Create and Destroy simply create and free the new FValues property, and then pass control
onto TDBComboBox’s constructor and destructor.
CreateWnd sends a CM_GETDATALINK message to the component to obtain a reference to the
component’s internal FDataLink field. Because TETHDBComboBox derives from TDBComboBox,
we’re actually retrieving TDBComboBox’s FDataLink. TDBComboBox.FDataLink is private and
TDBComboBox doesn’t provide a property to access the value, so there’s no way to directly get a
5
hold of the data link. Fortunately, TDBComboBox supports the CM_GETDATALINK message, which
DATA-AWARE
COMPONENTS
When it has a reference to the data link, CreateWnd sets up new event handlers for
OnDataChange and OnUpdateData. OnDataChange is fired automatically when the underlying
field data changes because either the current record changed, or because a new value was
assigned to the field. OnUpdateData is fired when the user selects a value in the combo box,
and the underlying field should be updated.
TDBComboBox provides a handler for both of these methods, but the handlers don’t take into
account our new FValues property. So, it’s necessary to override them.
SetValues is called when you assign a new string list to the Values property, like this:
ETHDBComboBox1.Values := MyStringList;
It first assigns the string list, and then calls DataChange directly, which ensures that the combo
box is updated to display the correct data.
GetComboValue is a helper function that retrieves the correct value for a given index. It first
checks the Values property to see if a value was assigned to the item in question. If so, it
returns the value from that list. If not, it returns the value directly from the Items list.
GetComboText returns the text for the currently displayed item in the combo box. If the combo
box allows text entry—in other words, if the style is csDropDown or csSimple—the function
simply returns the text displayed in the combo box. Otherwise, it calls GetComboValue to
obtain the value of the current item.
SetComboText works in reverse. It determines the index of a given string and makes that the
current ItemIndex of the component.
DataChange, as mentioned earlier, fires when the underlying field data changes. This method
simply calls SetComboText to update the text displayed in the combo box.
Conversely, UpdateData updates the underlying field so that it contains the correct value for
the currently selected combo box item.
TDBComboBox is used in a manner similar to the TDBRadioGroup component. If you leave the
Values list empty, the item selected in the combo box is stored directly in the underlying field,
which should be a string field. If the Values list is populated, the corresponding value is stored
in the underlying field, which can be either a string field or a numeric field (depending on
whether the Values list contains text or numbers).
TDBListBox
TDBListBox is conceptually identical to TDBComboBox because it enables the user to select an
item from a list of items. It also has the same limitation of TDBComboBox because it does not
support a Values property. For that reason, I’ve created my own version of TDBListBox.
Data-Aware Components
219
Listing 5.2 contains the source code for TETHDBListBox (a descendent of TDBListBox that sup-
ports a Values property).
interface
uses
Windows, Messages, SysUtils, Classes, Controls, StdCtrls, DBCtrls;
type
TETHDBListBox = class(TDBListBox)
private
{ Private declarations }
FDataLink: TFieldDataLink;
FValues: TStrings;
procedure DataChange(Sender: TObject);
procedure UpdateData(Sender: TObject);
function GetListValue(Index: Integer): string;
function IndexOfItem(const Value: string): Integer;
procedure SetValues(const Value: TStrings);
protected
{ Protected declarations }
procedure CreateWnd; override;
public
{ Public declarations }
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
published
{ Published declarations }
property Values: TStrings read FValues write SetValues;
end;
procedure Register;
implementation
procedure Register;
begin 5
RegisterComponents(‘ETH’, [TETHDBListBox]);
DATA-AWARE
COMPONENTS
end;
{ TETHDBListBox }
Chapter 5
220
inherited Create(AOwner);
end;
destructor TETHDBListBox.Destroy;
begin
FValues.Free;
inherited;
end;
procedure TETHDBListBox.CreateWnd;
begin
inherited;
Result := I;
end;
end.
The source code for TETHDBListBox is similar to that of TETHDBComboBox, so I won’t go into it
in detail here.
TDBImage
TDBImage is used to display bitmaps contained in a dataset’s BLOB field. Unfortunately,
TDBImage cannot be used to display nonbitmap images (such as JPEG, PNG, and the like).
Chapter 4, “Advanced Client Dataset Operations,” explains how you can store and retrieve
nonbitmap images from database BLOB fields. 5
DATA-AWARE
COMPONENTS
NOTE
Listing 3.4 showed how to display image data from a dataset without using a
TDBImage data-aware component.
Chapter 5
222
• TDBChart
• TDBCtrlGrid
These components are not included with CLX because they rely on one of the following:
underlying Win32 implementations (TDBRichEdit), not-yet-available third-party components
(TDBChart), or unsupported/obsolete functionality (TDBCtrlGrid).
Nevertheless, these components have use in VCL applications, so I’ll mention TDBRichEdit in
this chapter and TDBCtrlGrid in the next. Because date entry is something that many applica-
tions require, I’ll present a data-aware implementation of the Win32 TDateTimePicker compo-
nent later in this chapter.
TDBRichEdit is similar to TDBMemo because it is used to display and edit multiline text.
However, TDBMemo displays and edits unformatted text, while TDBRichEdit works with rich text
(text formatted using RTF, or rich text format).
Rich text enables the user to format paragraphs, words, or individual characters using different
font styles and formatting techniques—such as bullets, numbering, tabs, and indentation.
Although TDBRichEdit and its non–data-aware counterpart, TRichEdit, support this function-
ality through a wide array of properties and methods, it is up to you to provide the user with a
menu, a toolbar, or both to call the appropriate methods.
Without writing any code whatsoever, TDBRichEdit can still be used to display formatted text.
This is what the lookup data-aware controls are designed for—displaying a list from one
dataset and enabling the user to select an item to be stored in another dataset.
To complete the link to the lookup dataset, lookup data-aware components provide four addi-
tional properties: ListSource, ListField, KeyField, and ListFieldIndex.
• ListSource references the data source of the dataset from which to retrieve the list of
values.
• ListField is a semicolon-delimited list of field names that are to be displayed in the
component.
• KeyField determines the field whose value is to be stored in the dataset.
• ListFieldIndex is a zero-based number that determines the field to be used for incre-
mental searching in the component. For example, say that you set the ListField prop-
erty to FirstName;LastName. This instructs the component to display the first name and
the last name in the list. If you set ListFieldIndex to 1, as the user types into the con-
trol, it performs automatic incremental searching on the LastName field.
Lookup data-aware components consist of TDBLookupComboBox and TDBLookupListBox. These
components look and act like TDBComboBox and TDBListBox (respectively), except that rather
than populating the items manually, TDBLookupComboBox and TDBLookupListBox retrieve their
items from the dataset referenced through the component’s ListSource property.
NOTE
You can duplicate the functionality of the TETHDBComboBox and TETHDBListBox com-
ponents by using TDBLookupComboBox and TDBLookupListBox. To do this, create a
TClientDataSet that contains the Items and Values associations that are set in the
TETHDBComboBox or in the TETHDBListBox. If you only have a single occurrence of this
in your application, you might elect to go this route. However, if you have numerous
occurrences, your form becomes littered with lookup datasets and you might find it
easier to use TETHDBComboBox and TETHDBListBox instead.
TDBNavigator
The remaining components discussed in this chapter are used for displaying and editing data,
but TDBNavigator provides a code-free means of navigating and manipulating a dataset.
5
Visually, TDBNavigator looks like a toolbar because it contains a horizontal array of prede-
DATA-AWARE
COMPONENTS
Prior
Next
Last
Insert
Delete
Edit
Post
Cancel
When the user clicks one of the buttons in the TDBNavigator, VCL/CLX calls the correspond-
ing dataset method automatically. You can control which buttons are displayed through the
VisibleButtons property, which is implemented as a Pascal set: Simply remove the buttons
that you don’t want shown from the VisibleButtons property.
It is possible to change the image that appears on one or more of the buttons, although the
method for doing this isn’t well documented. TDBNavigator encapsulates a number of
TSpeedButtons to display the individual images, so you can access an individual speed button
through the controls array (as the following code snippet illustrates):
(DBNavigator1.Controls[0] as TSpeedButton).Glyph.LoadFromFile(‘C:\First.bmp’);
The index into the Controls array is a number between zero and nine, which refers to the
absolute position of the button within the navigator’s button array. However, a more fail-safe
method of accessing the button involves using the TNavigateBtn enumerated type, which is
defined in DBCtrls.pas like this:
TNavigateBtn = (nbFirst, nbPrior, nbNext, nbLast,
nbInsert, nbDelete, nbEdit, nbPost, nbCancel, nbRefresh);
Data-Aware Components
225
TFieldDataLink
TFieldDataLink is a helper class that establishes a link between the data-aware component
and the underlying dataset field. TFieldDataLink provides only a small number of methods,
properties, and events that you need to concern yourself with when writing a data-aware com-
ponent. Tables 5.9, 5.10, and 5.11 list the most often-used methods, properties, and events
(respectively).
Method Description
Edit Try to put the dataset into edit mode. Edit returns False if the dataset
does not allow editing, and returns True otherwise.
Modified Call the Modified method when the data-aware component is changed;
either because the user types into it, or because the contents of the
component were changed in some other way (such as through a click
or other interaction with the component).
Reset Call the Reset method when an action occurs that causes the contents
of the underlying field to be reset to its original value. For example, a
data-aware component might support a key (such as Ctrl+R) that resets
the original value of the field.
5
TABLE 5.10 TFieldDataLink Properties
DATA-AWARE
COMPONENTS
Property Description
CanModify Read-only property that returns True if the corresponding field can be
modified, and returns False if it cannot. CanModify returns False if
the dataset, the field, or the data-aware component is read-only.
Control References the link data-aware control.
Chapter 5
226
Event Description
OnDataChange Fires when there is a change to the underlying field.
OnEditingChange Fires when the associated data source changes from an editing mode to
a browse mode, or vice versa.
OnUpdateData Fires when the data contained in the data-aware component should be
written out to the dataset.
OnActiveChange Fires when the underlying dataset changes from active to inactive, or
vice versa.
The following sections explain how to incorporate a TFieldDataLink class into a component
to create a data-aware version of that component. They also show the proper way to make use
of the methods, properties, and events listed in the preceding tables.
Finally, you should handle the CM_GETDATALINK message and return a reference to the internal
TFieldDataLink field. CMGetDataLink provides this service in Listing 5.3.
NOTE
If you remember from the section titled “TDBComboBox,” we took advantage of the
CM_GETDATALINK message when writing the TETHDBComboBox and TETHDBListBox
components. If the authors of TDBComboBox and TDBListBox had not provided the
CM_GETDATALINK message handler, we would have no way of obtaining a reference to
the component’s internal TFieldDataLink.
write the code that updates the data when the component changes. To do that, we need to write
the UpdateData event handler.
Chapter 5
228
In many cases, UpdateData contains a single line of code, which gets the current value from
the data-aware component and writes it to the data field (as Listing 5.3 shows).
You also need to write one or more event handlers for the data-aware component that fires
when the value of the component is changed. In many cases, this includes a Change event han-
dler. In some cases, it requires a Click handler instead of (or in addition to) the Change event
handler. You should be familiar with the component that you are working with so that you
know what events might be fired as a result of a change to the component’s value.
In this case, I’ve overridden TDateTimePicker’s Click and Change dynamic methods to add
calls to the data link’s Edit and Modified methods. The logic is this: First, call Edit to attempt
putting the underlying dataset into edit mode. Next, call the component’s inherited method.
Finally, call Modified to let the data link know that the field was changed.
Message Handlers
Typically, a data-aware component updates the dataset when focus leaves the component. To
accomplish this, we must provide a message handler for the CM_EXIT message in the form of
the CMExit method shown in Listing 5.3.
The CMExit method attempts to update the dataset. If it fails for any reason, focus is set back to
the component and the exception is raised again. You can generally copy this message han-
dler’s code into your own data-aware components without modification.
Action Handlers
The final two methods that you should provide in your data-aware component are overrides for
ExecuteAction and UpdateAction. These overridden methods ensure that the component
works correctly with the standard DataSet actions provided with Delphi. Again, you can copy
the code verbatim from this component into your own data-aware components.
Data-Aware TDateTimePicker
Listing 5.3 contains the complete source code for TETHDBDateTimePicker (a data-aware
descendent of TDateTimePicker).
interface
uses
Windows, Messages, SysUtils, Classes, Controls, ComCtrls, DB, DBCtrls;
Data-Aware Components
229
procedure Register;
implementation
procedure Register;
begin
5
RegisterComponents(‘ETH’, [TETHDBDateTimePicker]);
DATA-AWARE
COMPONENTS
end;
{ TETHDBDateTimePicker }
Chapter 5
230
destructor TETHDBDateTimePicker.Destroy;
begin
FDataLink.Free;
FDataLink := nil;
inherited Destroy;
end;
procedure TETHDBDateTimePicker.Loaded;
begin
inherited Loaded;
if (csDesigning in ComponentState) then
DataChange(Self);
end;
procedure TETHDBDateTimePicker.Change;
begin
FDataLink.Edit;
inherited Change;
FDataLink.Modified;
end;
Data-Aware Components
231
end;
end.
Sample Application
Listing 5.4 is a sample application that makes use of many (but not all) of the data-aware com-
ponents discussed in this chapter. As you can see from Listing 5.4, there is very little code in
this application. Thanks to VCL/CLX, the data-aware components encapsulate almost every-
thing needed to display and update datasets in your applications.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, DB, DBClient,
QStdCtrls, QExtCtrls, QButtons, Mask, QComCtrls, QDBCtrls, QMask;
Data-Aware Components
233
lbWeekday: TDBListBox;
tabLookup: TTabSheet;
Label4: TLabel;
lbLookup: TDBLookupListBox;
Chapter 5
234
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
begin
// Create the lookup dataset and populate with some data
cdsLookup.CreateDataSet;
AddLookupItem(1, ‘Widgit’);
AddLookupItem(2, ‘Gadget’);
AddLookupItem(3, ‘Thingamabob’);
ClientDataSet1.CreateDataSet;
end;
Data-Aware Components
235
case DataSource1.State of
dsInactive: lblState.Caption := ‘Inactive’;
dsBrowse: lblState.Caption := ‘Browse’;
dsEdit: lblState.Caption := ‘Edit’;
dsInsert: lblState.Caption := ‘Insert’;
end;
end;
end.
FIGURE 5.3
DataAware demonstrates the use of many of the provided data-aware components.
Summary
This chapter introduced you to data-aware components. The components that we’ve covered
are
• TDataSource provides a high-level conduit between data-aware components and datasets.
• TDBText and TDBEdit are useful for displaying and editing simple field values.
• TDBMemo provides a means of displaying and editing unformatted multiline text.
• TDBRichEdit (a VCL-only data-aware component) can be used to display and edit for-
matted multiline text.
• TDBCheckBox and TDBRadioGroup support the selection of one or more options from an
available list of options.
• TDBComboBox and TDBListBox enable the user to select a field value from a list of prede-
fined values. I also provided you with code for descendents of these two components that
allows finer control over the value stored in the associated data field.
Data-Aware Components
237
5
DATA-AWARE
COMPONENTS
Data-Aware Grids CHAPTER
6
IN THIS CHAPTER
• TDBGrid 240
• TClientDataSetGrid 263
• TDBCtrlGrid 266
TDBGrid
TDBGridprovides the cornerstone for Delphi’s grid-based, data-aware components. Using
TDBGrid,you can create screens that look like the one shown in Figure 6.1 without a lot of
programming effort.
FIGURE 6.1
A sample screen created using a TDBGrid.
Later in this chapter, we’ll investigate the code required to produce this screen. In this section,
I’ll explore the TDBGrid component. The following sections introduce two other grids that are
either included with Delphi or are available as a free download.
The simplest way to use a TDBGrid is to drop it on a form, connect the data source, open the 6
dataset, and then run the application. If you do this, you’ll see a fairly mundane grid using all
default settings, as shown in Figure 6.2.
DATA-AWARE
GRIDS
FIGURE 6.2
The default grid is functional, but not eye-catching.
Figure 6.2 points out examples of title cells, data cells, indicator cells, and grid background.
Using a combination of the grid’s properties and events, you can create a grid that looks much
more pleasing to the eye. In the following sections, I’ll examine those properties and events
in detail.
Customizing Columns
Generally, the most basic level of customization that you want to perform is adjusting either
the number of columns that are displayed, or the order in which the columns are displayed.
TDBGrid published a Columns property, which provides access to the list of columns displayed
in the grid.
NOTE
You can think of the Columns property as being similar to a dataset’s Fields property.
If there are no columns specifically defined, the grid simply displays all columns in the
order that they appear in the dataset. If persistent fields are defined for the dataset,
the grid displays columns only for those fields.
To create persistent column objects for the grid (similar to a dataset’s persistent field
objects), you use the columns editor.
Chapter 6
242
Double-click the grid component at design time (or right-click it and select Columns Editor…
from the pop-up menu) to display the columns editor. The columns editor works like most
collection editors in Delphi—press Ins to create a new TColumn object, or right-click and select
Add from the pop-up menu.
Each column supports a number of properties that can be used to customize the column’s look
and feel. (For the ultimate in display flexibility, see the “Custom Drawing” section later in this
chapter.) These properties are listed in Table 6.1.
When you set the FieldName property, Delphi sets the Alignment and Width properties
automatically (based on the size and type of the field). Unfortunately, even though Delphi sets
Alignment to taRightJustify for a numeric field, it doesn’t automatically set the title’s
alignment to taRightJustify. So, you need to set the title’s alignment manually.
NOTE
It is possible to add a column for which no underlying data field exists. To do so,
insert a new column and leave the FieldName property blank. When doing this, you
need to set the column’s Alignment and Width properties manually, and you must use
the grid’s custom draw functionality to paint the cell contents for that column. For
example, you might want to create a column with no associated field to display an
icon in certain rows.
Data-Aware Grids
243
Column Types 6
Most columns are displayed and edited as a simple string. For cases in which you want the
DATA-AWARE
user to select from a list of values, or want to display a dialog that enables the user to select
the cell value, TDBGrid supports two types of embellishments that can be made to a column’s
GRIDS
active cell:
• A column can display a lookup combo box to enable the user to select from a predefined
list of values. If a column is linked to a lookup field in a dataset, the column automatically
displays a combo box of acceptable values when the user is editing that column.
• A column can display an ellipsis button, which can be programmed to display a dialog,
or programmed to perform some other function when the user clicks it.
The properties listed in Table 6.2 are used to set options for the various column styles.
For columns with a ButtonStyle of bsEllipsis, the grid’s OnEditButtonClick event is fired
when the user clicks the ellipsis button. The sample program presented at the end of this section
shows how you might respond to that event.
Column Titles
In addition to customizing the look of the column data, you can customize the look of the
columns’ titles. To change the font used for all column titles at the same time, you can set the
grid’s TitleFont property accordingly.
However, for control over each column title, you should resort to the individual column’s
Title property. The Title property expands to enable the following properties to be set for the
column title.
Chapter 6
244
Property Description
Alignment Sets the alignment of the column title to left-, center-, or right-justified.
Caption Specifies the text to be displayed in the column title.
Color Sets the background color of the column title.
Font Sets the font for the text displayed in the column title.
As mentioned earlier, Delphi does not automatically set the title’s Alignment property to
taRightJustify for numeric fields. So, you should make sure that you check the title’s
alignment when creating persistent columns.
Grid Options
After you have set up the columns that you want to be displayed in the grid, you can set
gridwide options that determine the overall look and feel of the grid. Table 6.4 lists the available
options.
Option Description
dgEditing The grid is editable. The user must press F2 to begin editing the
current cell. Note that individual columns can still be set to
read-only, which prevents editing in those columns. Setting the
dgRowSelect option automatically forces dgEditing off.
dgAlwaysShowEditor The grid is automatically placed into edit mode as soon as the user
tabs into a cell. The user does not need to press F2 to begin editing.
Like dgEditing, dgAlwaysShowEditor is forced off if dgRowSelect
is set.
dgTitles When this option is set, column titles are displayed.
dgIndicator This option forces the display of a narrow column at the extreme
left of the grid that shows the state of the current record (insert, edit,
or browse mode).
dgColumnResize Setting this option enables individual columns to be moved or
resized at runtime.
dgColLines When set, vertical lines are drawn between columns.
dgRowLines When set, horizontal lines are drawn between rows.
dgTabs When set, the user can press the Tab and Shift+Tab keys to move
from cell to cell in the grid. When clear, pressing Tab or Shift+Tab
causes focus to move to the next or the preceding control on the
form, respectively.
Data-Aware Grids
245
DATA-AWARE
dgRowSelect When set, clicking a row highlights the entire row rather than
GRIDS
selecting an individual cell. Row highlighting can also be
accomplished manually by custom drawing the grid, as explained
later in this chapter.
dgAlwaysShow Set this option to highlight the current cell even when the grid does
Selection not have focus.
dgConfirmDelete If the grid’s ReadOnly property is not set, this option causes the
VCL to display a delete confirmation message when the user
presses Ctrl+Delete while in the grid. If this option is not set, the
current record is deleted when the user presses Ctrl+Delete. Note that
Ctrl+Delete deletes the current record even if dgEditing is not set.
dgCancelOnExit This option affects how newly inserted rows are treated when the
user tabs out of the grid. When set, newly inserted rows for which
no data has been entered are canceled. If not set, inserted rows that
are left empty are posted to the dataset.
dgMultiSelect When set, the user can select multiple rows in the grid by pressing
Ctrl and clicking individual rows.
Events
In addition to the properties listed previously, TDBGrid provides a number of events that you
can respond to for finer control over the grid’s display and functionality. These events are
listed in Table 6.5.
Event Description
OnCellClick Fires when the user clicks a cell. Does not fire when the user clicks
a title cell, the indicator, or the grid background.
OnColEnter Fires immediately after focus enters the current column.
Chapter 6
246
The following example program, shown in Listing 6.1, demonstrates when the different grid
events are fired. The next section, “Custom Drawing” explores the OnDrawColumnCell event in
more detail.
interface
uses
SysUtils, Classes, QGraphics, QControls, QForms, QDialogs, DB, QGrids,
QDBGrids, DBClient, QExtCtrls, QStdCtrls, QDBCtrls;
type
TfrmMain = class(TForm)
ClientDataSet1: TClientDataSet;
DataSource1: TDataSource;
pnlOptions: TPanel;
pnlClient: TPanel;
grid: TDBGrid;
cbEditing: TCheckBox;
cbAlwaysShowEditor: TCheckBox;
cbTitles: TCheckBox;
cbIndicator: TCheckBox;
Data-Aware Grids
247
DATA-AWARE
cbColLines: TCheckBox;
GRIDS
cbRowLines: TCheckBox;
cbTabs: TCheckBox;
cbRowSelect: TCheckBox;
cbAlwaysShowSelection: TCheckBox;
cbConfirmDelete: TCheckBox;
cbCancelOnExit: TCheckBox;
cbMultiSelect: TCheckBox;
btnShowSelections: TButton;
DBNavigator1: TDBNavigator;
lbEvents: TListBox;
Label1: TLabel;
Label2: TLabel;
btnClearEventLog: TButton;
procedure FormCreate(Sender: TObject);
procedure gridCellClick(Column: TColumn);
procedure gridColExit(Sender: TObject);
procedure gridColEnter(Sender: TObject);
procedure gridColumnMoved(Sender: TObject; FromIndex,
ToIndex: Integer);
procedure gridEditButtonClick(Sender: TObject);
procedure cbEditingClick(Sender: TObject);
procedure cbAlwaysShowEditorClick(Sender: TObject);
procedure cbTitlesClick(Sender: TObject);
procedure cbIndicatorClick(Sender: TObject);
procedure cbColumnResizeClick(Sender: TObject);
procedure cbColLinesClick(Sender: TObject);
procedure cbRowLinesClick(Sender: TObject);
procedure cbTabsClick(Sender: TObject);
procedure cbRowSelectClick(Sender: TObject);
procedure cbAlwaysShowSelectionClick(Sender: TObject);
procedure cbConfirmDeleteClick(Sender: TObject);
procedure cbCancelOnExitClick(Sender: TObject);
procedure cbMultiSelectClick(Sender: TObject);
procedure btnShowSelectionsClick(Sender: TObject);
procedure btnClearEventLogClick(Sender: TObject);
private
procedure RetrieveOptions;
procedure UpdateOption(Option: TDBGridOption; Active: Boolean);
{ Private declarations }
public
{ Public declarations }
end;
Chapter 6
248
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
RetrieveOptions;
end;
procedure TfrmMain.RetrieveOptions;
begin
cbEditing.Checked := (dgEditing in grid.Options);
cbAlwaysShowEditor.Checked := (dgAlwaysShowEditor in grid.Options);
cbTitles.Checked := (dgTitles in grid.Options);
cbIndicator.Checked := (dgIndicator in grid.Options);
cbColumnResize.Checked := (dgColumnResize in grid.Options);
cbColLines.Checked := (dgColLines in grid.Options);
cbRowLines.Checked := (dgRowLines in grid.Options);
cbTabs.Checked := (dgTabs in grid.Options);
cbRowSelect.Checked := (dgRowSelect in grid.Options);
cbAlwaysShowSelection.Checked := (dgAlwaysShowSelection in grid.Options);
cbConfirmDelete.Checked := (dgConfirmDelete in grid.Options);
cbCancelOnExit.Checked := (dgCancelOnExit in grid.Options);
cbMultiSelect.Checked := (dgMultiSelect in grid.Options);
end;
RetrieveOptions;
end;
DATA-AWARE
UpdateOption(dgEditing, cbEditing.Checked);
GRIDS
end;
// By calling Abort here, you can prevent focus from leaving this column
// Abort;
end;
DATA-AWARE
begin
GRIDS
lbEvents.Items.Add(‘OnCellClick - Col ‘ + IntToStr(grid.SelectedIndex) +
‘, Field ‘ + grid.SelectedField.FieldName + ‘)’);
end;
// Command buttons
if grid.SelectedRows.Count = 0 then
raise Exception.Create(‘No rows selected’);
ShowMessage(s);
end;
end.
Chapter 6
252
FIGURE 6.3
The Options application lets you experiment with the TDBGrid component’s options.
Custom Drawing
As you can see from Figure 6.2, the grid’s default appearance is pleasing to look at, but not
especially eye-catching. Using custom drawing, we can spruce up the look of the grid
considerably.
To implement custom drawing in your grid, you need to handle the grid’s OnDrawColumnCell
event. You might notice that the grid contains a similarly named event, OnDrawDataCell.
OnDrawDataCell is an obsolete event that is included for backward compatibility with early
versions of Delphi. You should not use it in any new programming efforts.
A newly created handler for the OnDrawColumnCell event looks like this:
procedure TForm1.DBGrid1DrawColumnCell(Sender: TObject; const Rect: TRect;
DataCol: Integer; Column: TColumn; State: TGridDrawState);
begin
end;
As with all grid events, the Sender parameter references the grid object. Rect refers to the
bounding rectangle of the cell that is about to be drawn. DataCol is a zero-based index into the
absolute position of the column that is about to be drawn. State is a set containing one or
more of the values listed in Table 6.6.
Data-Aware Grids
253
DATA-AWARE
gdSelected The cell is selected.
GRIDS
gdFocused The cell has the focus.
gdFixed The cell is fixed (that is, it’s the indicator cell).
The difference between gdSelected and gdFocused can get confusing (especially because
these values change meaning slightly as the Options property changes), so I’ll clarify it here.
When dgRowSelect is not set (the default), only the current cell has the gdSelected value set.
If the grid currently has focus, the current cell has gdFocused set in addition to having the
gdSelected value set.
When dgRowSelect is set, all cells in the current row have the gdSelected value set. In
addition, if the grid has focus, the first cell in the row (excluding the indicator) has gdFocused
set. You probably want to ignore the gdFocused value when using dgRowSelect, as it has no
useful meaning.
In practice, you will often find that when you implement custom drawing, Delphi’s default
drawing code does about 90% of what you need. You might simply want to change the color of
selected cells, draw an image in a given column, or perhaps draw negative values in red.
It might seem that the DefaultDrawing property goes to extremes. On the one hand, if it is
True, the cell is drawn using its default settings, and then you turn around and draw over the
top of it. On the other hand, if it is False, you need to draw every single cell manually—even
those that you don’t need any special drawing for.
Fortunately, this isn’t the case. The solution is to set DefaultDrawing to False, and then inside
the OnCustomDrawColumn event handler, call the grid’s DefaultDrawColumnCell, like this:
procedure TForm1.DBGrid1DrawColumnCell(Sender: TObject; const Rect: TRect;
DataCol: Integer; Column: TColumn; State: TGridDrawState);
Chapter 6
254
begin
if Column.FieldName = ‘Salary’ then begin
if Column.Field.AsFloat > 50000.0 then begin
DBGrid1.Canvas.Brush.Color := clYellow;
This code snippet only changes the way the Salary column is drawn. If the salary is greater
than $50,000, the background of the cell is drawn in yellow. If the cell is focused, the salary is
drawn in red.
For all other cells, and for salaries that are less than or equal to $50,000, the cell is drawn
normally. The call to DefaultDrawColumnCell takes care of drawing the cell after the appropriate
changes (if any) are made to the brush and font colors.
The way that Delphi’s internal VCL/CLX painting code works, all you need to do in this
handler is to set the canvas’ Brush and Font properties so that they reflect the color and font
that you want to use when painting the cell. The call to DefaultDrawColumnCell then uses the
settings that you specified when drawing the cell contents.
NOTE
Notice, in the preceding code snippet, that I checked the FieldName property of the
column to see if the code is drawing the Salary column. You might be tempted to
use the DataCol parameter to check for this. However, DataCol is the zero-based
absolute index of the cell being drawn. If the user reorders the columns at runtime,
this value changes.
The following sample program demonstrates several ways to custom draw grid cells. Listing
6.2 contains the complete source code for the CustomDraw application.
interface
Data-Aware Grids
255
DATA-AWARE
SysUtils, Types, Classes, QGraphics, QControls, QForms, QDialogs, DB,
GRIDS
DBClient, QGrids, QDBGrids, QExtCtrls, DateUtils;
type
TfrmMain = class(TForm)
pnlClient: TPanel;
DBGrid1: TDBGrid;
DataSource1: TDataSource;
ClientDataSet1: TClientDataSet;
Image1: TImage;
procedure FormCreate(Sender: TObject);
procedure DBGrid1DrawColumnCell(Sender: TObject; const Rect: TRect;
DataCol: Integer; Column: TColumn; State: TGridDrawState);
private
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
end.
Finally, the code checks the birthday of the employee. If the employee is 50 years old (or
older), a watch icon is drawn in the first column. The first column has an ID of 0. Notice that
the code doesn’t check the DataCol parameter to see if it’s 0 because the user could rearrange
the columns at runtime. Instead, it checks for the column ID, which is a zero-based integer that
was established at design time and doesn’t change.
NOTE
If the dgIndicator option were turned on, the column ID would be 1 instead of 0
because the indicator column would have an ID of 0.
Data-Aware Grids
257
DATA-AWARE
TDBGrid component. The solutions to these problems are not overly difficult, but in most cases
GRIDS
they involve more than simply calling a method or setting a property.
To get the index of the focused column, you can access the grid’s SelectedIndex property.
SelectedIndex is a zero-based number indicating the absolute position of the selected column.
SelectedIndex adjusts for the indicator, so if the indicator is present, the first data column is
index 1. If the indicator is not displayed, the first data column is index 0.
To retrieve the field object for the current column, you can reference the grid’s SelectedField
property. SelectedField returns the underlying dataset’s TField object, so you can directly
access it to retrieve the value of the current cell.
ShowMessage(‘The current cell value is ‘ + DBGrid1.SelectedField.AsString);
NOTE
If the current column is not connected to a dataset field, SelectedIndex returns –1
and SelectedField returns nil. If you display any columns in your grid that are not
tied to a field, you should always check for –1 or nil before attempting to do some-
thing with the SelectedIndex or SelectedField properties.
For example, say that you want to write an event handler that tracks the current position of the
mouse and provides information about the field at that location. A more ambitious project
might be to write code that displays a tooltip when the mouse hovers over a cell whose contents
are too long to be fully displayed in the cell.
In either case, you can call the grid’s MouseCoord method passing in the X and Y coordinates
of the mouse relative to the grid control. MouseCoord passes back a TGridCoord structure,
which contains X and Y fields representing the absolute column and row indexes of the cell at
that mouse position.
TGridCoord’s X and Y values deserve a little explanation. These are absolute indexes, meaning
that they take the indicator column and title rows into account. If the indicator is displayed, the
X position of the first data column is 1. If the indicator is not displayed, it is 0. This is also true
for the rows: If the grid titles are not displayed (dgTitles is not set in the grid’s Options
property), the Y position of the first row of data is 0. If titles are displayed, it is 1.
If the mouse position is not over a cell (for instance, the mouse is over the background area of
the grid), the returned TGridCoord’s X and Y values are both –1.
The following code snippet shows how you might update a label to show the X and Y
positions, as well as the field name, of the cell at the current mouse position.
procedure TForm1.DBGrid1MouseMove(Sender: TObject; Shift: TShiftState; X,
Y: Integer);
var
GC: TGridCoord;
IndicatorOffset: Integer;
TitleOffset: Integer;
begin
GC := DBGrid1.MouseCoord(X, Y);
if GC.X = -1 then
Label1.Caption := IntToStr(GC.X) + ‘, ‘ + IntToStr(GC.Y)
else begin
if dgIndicator in DBGrid1.Options then
IndicatorOffset := 1
else
IndicatorOffset := 0;
‘ - Indicator’
else
6
Label1.Caption := IntToStr(GC.X) + ‘, ‘ + IntToStr(GC.Y) + ‘ - ‘ +
DATA-AWARE
DBGrid1.Columns[GC.X - IndicatorOffset].FieldName;
GRIDS
// To move the dataset to the corresponding record, clone the dataset,
// and set the clone’s RecNo property to GC.Y - TitleOffset.
end;
end;
At the end of that code snippet is a comment explaining how to retrieve the data for the
appropriate cell. Here is the code required to do that (assuming that the grid is connected to
a client dataset):
var
CloneDS: TClientDataSet;
FieldValue: string;
begin
...
CloneDS := TClientDataSet.Create(nil);
try
CloneDS.CloneCursor(DBGrid1.DataSource.DataSet);
CloneDS.RecNo := GC.Y - TitleOffset;
NOTE
For this code to work, you must make sure the dgEditing option is set. If it isn’t, the
grid doesn’t enter edit mode even when the code sets EditorMode to True.
interface
uses
Windows, Messages, SysUtils, Classes, Controls, Grids, DBGrids;
type
TETHDBGrid = class(TDBGrid)
private
{ Private declarations }
FOnColumnSized: TNotifyEvent;
protected
{ Protected declarations }
procedure ColWidthsChanged; override;
public
{ Public declarations }
published
{ Published declarations }
Data-Aware Grids
261
DATA-AWARE
read FOnColumnSized write FOnColumnSized;
GRIDS
end;
procedure Register;
implementation
procedure Register;
begin
RegisterComponents(‘ETH’, [TETHDBGrid]);
end;
{ TETHDBGrid }
procedure TETHDBGrid.ColWidthsChanged;
begin
inherited ColWidthsChanged;
if Assigned(FOnColumnSized) then
FOnColumnSized(Self);
end;
end.
The overridden method, ColWidthsChanged, calls the inherited version, and then fires the
OnColumnSized event (if you provide an event handler for it in your application).
This code does nothing more than notify you that a column was resized. It doesn’t tell you
which column was resized, what the old width was, or what the new width is. Providing that
additional information would require a lot more work and duplicate a good deal of code that is
in TDBGrid. I prefer to keep my descendent components simple and direct.
NOTE
TClientDataSetGrid (discussed later in this chapter) does not provide this event han-
dler, either. So, if you intend to use TClientDataSetGrid instead of TDBGrid in your
applications, you might want to change the code shown here so that it derives from
TClientDataSetGrid.
Chapter 6
262
The drawback to this approach is that if you have many different grids in your application, you
need separate configuration files to persist each one. If that is the case, you might want to look
at SaveToStream instead of SaveToFile. With a little effort, you can use SaveToStream to save
a grid’s column configuration to an ini file or to the Windows registry. The following procedure
saves column information to an ini file:
procedure SaveColumnConfiguration(const FileName: string; Grid: TDBGrid;
const SectionName: string; const Name: string);
var
ini: TIniFile;
MemStream: TMemoryStream;
begin
MemStream := TMemoryStream.Create;
try
Grid.Columns.SaveToStream(MemStream);
MemStream.Seek(0, soFromBeginning);
ini := TIniFile.Create(FileName);
try
ini.WriteBinaryStream(SectionName, Name, MemStream);
finally
ini.Free;
end;
finally
MemStream.Free;
end;
end;
Data-Aware Grids
263
This code first creates a memory stream and saves the column configuration to that stream. 6
Next, it writes the stream out to the ini file. With minor modifications, you could change this
code to use the Windows registry instead of an ini file.
DATA-AWARE
GRIDS
Similarly, the following procedure loads the column information back from the ini file:
procedure LoadColumnConfiguration(const FileName: string; Grid: TDBGrid;
const SectionName: string; const Name: string);
var
ini: TIniFile;
MemStream: TMemoryStream;
begin
MemStream := TMemoryStream.Create;
try
ini := TIniFile.Create(FileName);
try
ini.ReadBinaryStream(SectionName, Name, MemStream);
if MemStream.Size > 0 then
Grid.Columns.LoadFromStream(MemStream);
finally
ini.Free;
end;
finally
MemStream.Free;
end;
end;
Limitations
For all its power, TDBGrid does have some limitations. The most notable one is that it doesn’t
display memos or images. You can draw images or memos manually using the custom drawing
features of the grid, but because each grid row is the same height, this can lead to difficulties
when one memo is three lines long and another is thirty lines long.
This limitation (as well as others) is removed by many of the third-party grids available. At the
end of this chapter, there is a quick overview of some of the third-party grids that you might
want to look into.
TClientDataSetGrid
TClientDataSetGrid is a TDBGrid descendent written by John Kaster. It takes advantage of
some of the functionality of client datasets to provide automatic sorting of the grid when the
user clicks a column title.
In addition, TClientDataSetGrid can automatically persist column information to and from a
separate configuration file. It doesn’t support saving and loading column information to and
Chapter 6
264
from an ini file, or to and from the Windows registry, so you might still want to make use of
the SaveColumnConfiguration and LoadColumnConfiguration procedures provided in the
preceding section. (TClientDataSetGrid is available as ID 15099 on Code Central at
http://codecentral.borland.com.)
Automatic Sorting
As indicated previously, TClientDataSetGrid enables the user to sort the grid in ascending or
descending order, on a single column or on multiple columns. It can even sort one column in
ascending order and another column in descending order.
To enable this functionality, you must set the component’s TitleSort property to True. (It is
False by default, allowing the component to be used with nonclient datasets. If you set
TitleSort to True, TClientDataSetGrid does not work with datasets that do not derive from
TCustomClientDataSet.)
To indicate the current sort order, TClientDataSetGrid draws three-dimensional arrows in the
title of the sorted column(s). The colors used to draw these arrows are set through the
ArrowColor, ArrowHighlight, and ArrowShade properties.
Figure 6.4 shows how the grid looks when sorted by Name, and then by Birthday.
FIGURE 6.4
TClientDataSetGrid provides visual feedback about the current sort order.
To sort, click one of the column titles. An up arrow will be drawn in the title cell of that column.
To switch to descending order, click the column title again.
If you want to sort on more than one column, press Shift and click the next column title to sort
on. Press Shift and click the column title a second time to sort in descending order on that
column only. Pressing Shift and clicking the column a third time removes it from the current
sort order. Repeat this for every column you want to sort on.
Data-Aware Grids
265
This is actually easier done than said. If the previous explanation sounds complicated, you 6
might want to play with clicking, and pressing shift and clicking, column titles to see the effect
for yourself.
DATA-AWARE
GRIDS
Column Customization
In addition to automatic sorting capabilities, TClientDataSetGrid provides a separate dialog
that can be used to set the visible columns for the grid (as shown in Figure 6.5). To display this
dialog, call the grid’s ConfigureColumns method, like this:
ClientDataSetGrid1.ConfigureColumns;
FIGURE 6.5
TClientDataSetGrid enables the user to hide columns that he doesn’t want to see.
Using this dialog, the user can hide or show individual columns.
If you want the grid to automatically save and restore its column configuration (including
column order and the visibility state of individual columns), set the ConfigFile property to
the name of the file that you want to use for persisting the column information. Make sure to
use a different filename for each grid, as the current version of this component doesn’t support
saving multiple configurations in a single file.
Chapter 6
266
NOTE
Because the code for TClientDataSetGrid is freely available, I hope to see some
enterprising Delphi programmers providing enhancements to it in the future. My per-
sonal wish list includes
• Saving and loading column configuration to and from an ini file and the
Windows registry.
• Enhancing the Configure Columns dialog to support column reordering in addi-
tion to hiding or showing columns.
• Enhancing the Configure Columns dialog to support locking individual columns
so that they cannot be hidden or moved.
• Adding support for an OnColumnSized event.
TDBCtrlGrid
I’m not going to spend a lot of time on TDBCtrlGrid because it isn’t CLX-compatible, and
because there isn’t any new development going on in terms of TDBCtrlGrid.
TDBCtrlGrid is a grid-like component, although it relies on other data-aware components to
perform the actual data input and output. To use a TDBCtrlGrid in your application, drop it on
a form and connect the DataSource property to your data source. Then, populate the grid with
other data-aware components, such as TDBEdit, TDBCheckBox, and so on.
TDBCtrlGrid replicates these components at runtime, displaying each component for every
record displayed in the grid. Every cell in the grid corresponds to a single record in the dataset.
Most data-aware controls are replicable (that is, they can be used in a TDBCtrlGrid). Some are
not (including the TETHDBDateTimePicker component that I created in the preceding chapter).
In order for the control to be replicable, it must include the csReplicatable option in its
ControlStyle property (typically set in the component’s constructor). The following is a snippet
from TDBEdit’s constructor:
constructor TDBEdit.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
inherited ReadOnly := True;
ControlStyle := ControlStyle + [csReplicatable];
...
end;
The following sections discuss the properties and events that TDBCtrlGrid introduces.
Data-Aware Grids
267
Properties 6
TDBCtrlGrid introduces a handful of properties that you can use to customize its look and feel.
DATA-AWARE
Table 6.7 lists these properties.
GRIDS
TABLE 6.7 TDBCtrlGrid Properties
Property Description
AllowInsert When True, the user can scroll past the last record in the grid to
insert a new record.
AllowDelete When True, the user can delete the current record by pressing
Ctrl+Delete.
ColCount Determines the number of columns displayed in the grid.
Orientation Determines the direction in which the grid scrolls to display more data.
PanelBorder Possible values are gbNone and gbRaised. gbRaised causes the grid
to have a raised look. You can achieve other looks, such as lowered
or bump, by setting PanelBorder to gbNone and dropping the
TDBCtrlGrid on a panel with the desired bevel.
PanelHeight Refers to the height, in pixels, of a single panel (cell).
PanelWidth Refers to the width, in pixels, of a single panel (cell).
RowCount Determines the number of rows displayed at a time in the grid.
SelectedColor Determines the background color of the current cell.
ShowFocus When True, a focus rectangle is drawn around the current cell.
The ColCount, PanelWidth, and Width properties are directly related. ColCount×PanelWidth is
approximately equal to Width (allowing for the grid border and vertical scrollbar). Setting
ColCount automatically adjusts the Width, as long as the Align setting does not prevent it.
(Setting Align to alClient, for example, does not allow the grid to resize. In this case, setting
ColCount automatically adjusts PanelWidth.)
Similarly, the RowCount, PanelHeight, and Height properties are related. Setting one property
affects the others.
Events
TDBCtrlGrid only contains one new event, named OnPaintPanel. OnPaintPanel fires just
before each panel is about to be drawn. OnPaintPanel looks like this:
procedure TForm1.DBCtrlGrid1PaintPanel(DBCtrlGrid: TDBCtrlGrid;
Index: Integer);
begin
end;
Chapter 6
268
Index refers to the zero-based index of the panel about to be drawn, and is a number between
zero and RowCount—one, inclusive.
You might notice that there is no Rect parameter passed to this function, so at first glance it
isn’t obvious how to determine the bounding rectangle of the current cell. Upon entry to this
method, the grid canvas’ origin is set to the upper-left corner of the current panel. In other
words, point (0, 0) on the canvas refers to the upper-left corner of the panel. Point
(PanelWidth, PanelHeight) references the lower-right corner. This enables you to use the
canvas for such things as drawing a background image on the panel (as the following code
snippet taken from Listing 6.4 shows).
procedure TForm1.DBCtrlGrid1PaintPanel(DBCtrlGrid: TDBCtrlGrid;
Index: Integer);
begin
if Index <> DBCtrlGrid.PanelIndex then
DBCtrlGrid1.Canvas.Draw(0, 0, Image1.Picture.Graphic);
end;
This code checks the index passed into the method to see if we’re drawing the current panel.
(The public property PanelIndex contains the number of the current panel.) All noncurrent
panels are drawn with a background graphic.
Listing 6.4 contains the complete source code for the CtrlGrid demo application, which
enables you to play with some of TDBCtrlGrid’s properties.
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, dbcgrids, ExtCtrls, DB, DBClient, StdCtrls, Mask, DBCtrls,
ComCtrls;
type
TForm1 = class(TForm)
ClientDataSet1: TClientDataSet;
DataSource1: TDataSource;
pnlClient: TPanel;
DBCtrlGrid1: TDBCtrlGrid;
ecID: TDBEdit;
ecName: TDBEdit;
ecSalary: TDBEdit;
Label1: TLabel;
Label2: TLabel;
Data-Aware Grids
269
DATA-AWARE
Label4: TLabel;
GRIDS
ecBirthday: TDBEdit;
pnlBottom: TPanel;
cbAllowInsert: TCheckBox;
cbAllowDelete: TCheckBox;
ecRowCount: TEdit;
ecColCount: TEdit;
cbShowFocus: TCheckBox;
Label5: TLabel;
Label6: TLabel;
Label7: TLabel;
cbOrientation: TComboBox;
Image1: TImage;
procedure FormCreate(Sender: TObject);
procedure cbAllowInsertClick(Sender: TObject);
procedure cbAllowDeleteClick(Sender: TObject);
procedure cbShowFocusClick(Sender: TObject);
procedure ecRowCountChange(Sender: TObject);
procedure ecColCountChange(Sender: TObject);
procedure cbOrientationClick(Sender: TObject);
procedure DBCtrlGrid1PaintPanel(DBCtrlGrid: TDBCtrlGrid;
Index: Integer);
private
{ Private declarations }
public
{ Public declarations }
end;
var
Form1: TForm1;
implementation
{$R *.dfm}
end.
DATA-AWARE
GRIDS
FIGURE 6.6
CtrlGrid demonstrates TDBCtrlGrid’s behavior.
Product Description
Orpheus TurboPower Software Company’s flagship Delphi-VCL add-on
library contains two data-aware grid components that can be used
for formatted data entry, multilevel data display, and automatic
subtotaling and totaling of columns. Visit www.turbopower.com
for more information about Orpheus or to download a free trial
version.
InfoPower 2000 This popular general-purpose Delphi library includes the
TwwDBGrid component, which works like TDBGrid and adds new
functionality (such as memo display and automatic footers). Go
to www.woll2woll.com for more information about the
InfoPower product.
ExpressQuantumGrid An extremely powerful TDBGrid replacement that offers numerous
advanced grid features—too many to list here. Some of the
notable features include multiline data display, runtime sorting
Chapter 6
272
Summary
This chapter continued the discussion of data-aware components with an overview of data-aware
grids, which allow multiple rows of a dataset to be displayed on the screen at the same time.
Specifically, this chapter taught you the following:
• You can create a quick and dirty grid by dropping a TDBGrid component on a form and
using the default values. To customize the columns in the resulting grid, use the Columns
property.
• Various grid options exist for altering the look and feel of the grid. (See Table 6.4 for a
summary.)
• You can use various grid events to gain control over what happens when the user clicks a
cell or performs some other action. (See Table 6.5 for a summary.)
• For the ultimate control over how the grid looks, use the OnDrawColumnCell event.
• TClientDataSetGrid is a free component that you can use for automatic column sorting
and additional column customization.
• TDBCtrlGrid is a VCL-specific component that offers the capability to arrange data for a
single record in a nonlinear format.
In addition, this chapter showed how to deal with several commonly encountered grid issues,
such as detecting when a column is moved and persisting column states.
The following chapter begins a two-chapter exploration of client datasets.
Dataset Providers CHAPTER
7
IN THIS CHAPTER
• What Is a Dataset Provider? 274
Earlier in this book, you learned about dbExpress—a high-performance, low-overhead data-
base technology. You learned that it is read-only and unidirectional, making it cumbersome to
use directly for most interactive database applications.
Later, I introduced you to client datasets. Client datasets are especially well suited to database
application front ends because they are fast, flexible, and powerful. However, they are inher-
ently single user because they are RAM-based. Also, they read from and write to a proprietary
file format.
So far, these may seem like disparate technologies used to solve different types of program-
ming problems. This chapter and the next tie them together, showing you how you can use
client datasets to read and write data using dbExpress as the underlying database technology.
This chapter shows you how to use providers to create multitier database applications.
NOTE
Nothing about dataset providers ties them to dbExpress specifically. The information
presented in this chapter is applicable to other database technologies as well, such as
BDE and dbGo (formerly called ADOExpress).
For a dataset to be compatible with TDataSetProvider, it must support the
IProviderSupport interface, defined in DB.pas like this:
IProviderSupport = interface
procedure PSEndTransaction(Commit: Boolean);
procedure PSExecute;
function PSExecuteStatement(const ASQL: string; AParams: TParams;
ResultSet: Pointer = nil): Integer;
procedure PSGetAttributes(List: TList);
function PSGetDefaultOrder: TIndexDef;
function PSGetKeyFields: string;
function PSGetParams: TParams;
function PSGetQuoteChar: string;
function PSGetTableName: string;
Dataset Providers
275
PROVIDERS
Delta: TDataSet): Boolean;
DATASET
end;
TDataSet implements stub functions for these methods, which generally do nothing
or raise an exception. The datasets included with Delphi (BDE, dbExpress, and dbGo)
override these methods to provide a specific implementation.
Connecting to a Dataset
In this section I’ll show you how to connect a client dataset to another dataset. To do so, follow
these steps:
1. Start a new application and drop a TSQLConnection and TSQLDataSet on the main form.
Connect these components to a database, using the techniques discussed in Chapters 1
and 2.
2. Drop a TDataSetProvider on the main form. Set the DataSet property to the
TSQLDataSet component. For now, leave all other properties set to their default values.
3. Drop a TClientDataSet on the main form. Set its ProviderName property to the dataset
provider you created in the previous step.
4. Connect a TDataSource component to the client dataset and hook up a TDBGrid compo-
nent to the data source.
5. Create an event handler for the form’s FormCreate event and add the following line of
code to it:
ClientDataSet1.Open;
can make changes to the data. However, if you leave the application and run it again, none of
your changes have been saved to the database. We’ll remedy that situation in the next section.
For now, I just want you to see how easy it is to establish the relationship between a client
dataset and another dataset. This relationship is important because dbExpress datasets don’t
support editing or bidirectional scrolling on their own—they must be connected to a client
dataset to provide these capabilities.
Figure 7.1 shows the results of the preceding steps after connecting to the CONMAN database.
FIGURE 7.1
Sample application at design time and runtime.
You should leave this sample application loaded in Delphi or save it to disk somewhere; we’ll
embellish on it in the following sections.
Applying Updates
To save changes to the database permanently, you need to call the client dataset’s
ApplyUpdates method. ApplyUpdates detects that the dataset is connected to a provider and
takes care of sending changes back through the provider to the database.
Dataset Providers
277
Add a button to your sample application and create an OnClick handler for it. In the OnClick
handler, add the following code:
if ClientDataSet1.ApplyUpdates(0) > 0 then
ShowMessage(‘Failed to update database’);
Now run the application again, modify some data, and click the button. If you quit the applica-
tion and rerun it, you’ll see that the changes were indeed saved to the database.
The call to ApplyUpdates takes a single parameter, which indicates the “tolerance level” for
errors. In this case, I’ve specified a zero-error tolerance level. What this means is that if any
errors occur during the update process, the changes are rolled back and none of the updates are 7
committed to the underlying database.
PROVIDERS
DATASET
NOTE
When resolving data to a database, VCL/CLX automatically wraps the updates in a
transaction, so either all the changes are made or none of them are. You don’t need
to write any code to deal with transactions in this case.
At times, you might be willing to tolerate one or more errors when resolving data. For
instance, if the user changes three rows in the grid, but only two of the changes can be saved
successfully, you might still want those two changes saved. If this is the case, pass the maxi-
mum number of errors that you will allow to ApplyUpdates. If you don’t care how many errors
occur, call ApplyUpdates with a parameter of –1.
After the call to ApplyUpdates, any successful updates are removed from the client dataset’s
change log. If any rows could not be updated, they are left in the change log.
You may be wondering why the provider might not be able to save changes to the underlying
database. The most common reason is that another user changed the same row while you were
viewing it or deleted the row before you had a chance to save your changes. Other reasons
may include a broken connection to the database server.
NOTE
TDataSetProvider and TClientDataSet give you control over how to detect and
respond to data clashes. Later in this chapter, I’ll cover some of the various techniques
you can use.
Chapter 7
278
Resolving to a Dataset
TDataSetProvider publishes a property named ResolveToDataSet. By default it is False,
indicating that the provider resolves data directly to the database server associated with the
provider’s DataSet. This is generally the most efficient way to resolve data.
In some cases, you must set ResolveToDataSet to True. The most common reasons are listed
next:
• The provider’s DataSet is not connected to a database—for example, it is a
TClientDataSet.
• The provider’s DataSet does not provide the necessary implementation of the
IProviderSupport interface.
In these cases, updates will be applied to the dataset referenced by the provider’s DataSet
property. You can then handle the provider’s AfterApplyUpdates method to make those
changes persistent (in the case of a TClientDataSet, you could call the dataset’s SaveToFile
method, for example). AfterApplyUpdates, as well as other provider events, are discussed in
the section titled “Provider Events,” later in this chapter.
Reconciliation Errors
By default, if one or more errors occur during reconciliation, ApplyUpdates returns a number
greater than zero, indicating the number of errors that occurred. This is fine if all you want to
know is whether there were errors. However, it doesn’t give you any control over how to han-
dle reconciliation errors.
For greatest control over reconciliation errors, you should provide an event handler for the
client dataset’s OnReconcileError event. An empty handler looks like the following:
procedure TfrmMain.ClientDataSet1ReconcileError(
DataSet: TCustomClientDataSet; E: EReconcileError;
UpdateKind: TUpdateKind; var Action: TReconcileAction);
Begin
end;
DataSet refers to the client dataset for which the reconciliation error occurred. E is an excep-
tion that gives more information about the error. UpdateKind is one of the values listed in
Table 7.1. You should set Action to one of the values listed in Table 7.2, which instructs the
VCL as to what action to take for the offending record.
The OnReconcileError event is fired for each offending record. If eight updates are resolved
back to the provider and two of them conflict with prior changes made by another user,
OnReconcileError is fired twice. Depending on the action taken for each record, the number
returned from ApplyUpdates may be 0, 1, or 2. This is explained in more detail in Table 7.2.
Dataset Providers
279
The value returned from ApplyUpdates is also dependent on the parameter passed to
ApplyUpdates. The return value will never be more than one greater than the value specified
by the parameter. For instance, if you pass 0 to ApplyUpdates (which is typically what’s done),
the return value will be either 0 or 1.
PROVIDERS
DATASET
TABLE 7.2 TReconcileAction Values
Value Description
raSkip Don’t apply updates to this record. Leave the unapplied changes in the
client dataset’s change log. This record will be counted in the return
value from ApplyUpdates.
raAbort Abort the whole operation. No updates are made to the underlying
database at all, and all changes are left in the client dataset’s change
log. All records are counted in the return value from ApplyUpdates.
raMerge Merge the record with the record in the underlying database. This
works only if the fields that are changed in the record don’t conflict
with fields that were changed by someone else. This record will not be
counted in the return value from ApplyUpdates. You must set the
pfInKey flag (discussed later in the section titled “Update Modes”) for
all fields in the primary key for this option to be available.
raCorrect Indicates that changes were made to the current record inside the
OnReconcileError event handler. VCL/CLX should try to update again
with the new field values.
raCancel Cancel changes to this record, reverting to the original record data.
This record will not be counted in the return value from ApplyUpdates.
raRefresh Cancel changes to this record and reread the record data from the data-
base. This record will not be counted in the return value from
ApplyUpdates. You must set the pfInKey flag for all fields in the pri-
mary key for this option to be available.
Chapter 7
280
Fortunately, in most situations you don’t have to worry about writing a complicated event han-
dler for OnReconcileError. Delphi comes with a prewritten class that you can use to handle
reconcile errors. To use this class, perform the following steps:
1. From the Delphi main menu, Select File, New, Other.
2. On the Dialogs tab of the New Items dialog, select the Reconcile Error Dialog icon.
Make sure Copy is selected in the option buttons below the list of icons (see Figure 7.2).
3. Click OK.
4. Save the resulting unit as something like ReconcileErrorForm.pas. (the name doesn’t
matter).
5. Add the new unit to the uses clause of the form that contains the client dataset (in this
example, it’s the main form).
6. Add the following code to the OnReconcileError event for the client dataset:
Action := HandleReconcileError(DataSet, UpdateKind, E);
I won’t go into detail about the inner working of HandleReconcileError here. You should take
a look at the unit’s source code to gain an understanding of how it works.
FIGURE 7.2
Inserting a TReconcileErrorForm into your application.
Listing 7.1 contains the source code for the main form of the Updates sample application,
which illustrates the concepts discussed so far in this chapter.
Dataset Providers
281
interface
uses
SysUtils, Variants, Classes, QGraphics, QControls, QForms, QStdCtrls,
QDialogs, QExtCtrls, DBXpress, FMTBcd, QGrids, QDBGrids, DB, Provider,
DBClient, SqlExpr, QDBCtrls, QTypes;
type 7
TfrmMain = class(TForm)
pnlClient: TPanel;
PROVIDERS
DATASET
pnlBottom: TPanel;
SQLConnection1: TSQLConnection;
SQLDataSet1: TSQLDataSet;
ClientDataSet1: TClientDataSet;
DataSetProvider1: TDataSetProvider;
DataSource1: TDataSource;
DBGrid1: TDBGrid;
btnApplyUpdates: TButton;
btnCancelUpdates: TButton;
lblUpdates: TLabel;
Timer1: TTimer;
DBNavigator1: TDBNavigator;
SQLDataSet1CONTACTID: TIntegerField;
SQLDataSet1FIRST: TStringField;
SQLDataSet1LAST: TStringField;
SQLDataSet1DEAR: TStringField;
SQLDataSet1TITLE: TStringField;
SQLDataSet1COMPANYNAME: TStringField;
SQLDataSet1ADDRESS1: TStringField;
SQLDataSet1ADDRESS2: TStringField;
SQLDataSet1CITY: TStringField;
SQLDataSet1STATE: TStringField;
SQLDataSet1POSTALCODE: TStringField;
SQLDataSet1COUNTRY: TStringField;
SQLDataSet1PHONE: TStringField;
SQLDataSet1FAX: TStringField;
SQLDataSet1CELLULAR: TStringField;
SQLDataSet1PAGER: TStringField;
SQLDataSet1EMAIL: TStringField;
SQLDataSet1IMAGE: TBlobField;
SQLDataSet1NOTES: TMemoField;
sqlID: TSQLDataSet;
lbEvents: TListBox;
Chapter 7
282
PROVIDERS
DATASET
procedure ClientDataSet1AfterRowRequest(Sender: TObject;
var OwnerData: OleVariant);
procedure ClientDataSet1BeforeApplyUpdates(Sender: TObject;
var OwnerData: OleVariant);
procedure ClientDataSet1BeforeExecute(Sender: TObject;
var OwnerData: OleVariant);
procedure ClientDataSet1BeforeGetParams(Sender: TObject;
var OwnerData: OleVariant);
procedure ClientDataSet1BeforeGetRecords(Sender: TObject;
var OwnerData: OleVariant);
procedure ClientDataSet1BeforeRefresh(DataSet: TDataSet);
procedure ClientDataSet1BeforeRowRequest(Sender: TObject;
var OwnerData: OleVariant);
procedure btnClearEventLogClick(Sender: TObject);
private
function GetNextID: Integer;
procedure Log(const s: string);
{ Private declarations }
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
uses RecErrorForm;
{$R *.xfm}
procedure TfrmMain.ClientDataSet1ReconcileError(
DataSet: TCustomClientDataSet; E: EReconcileError;
UpdateKind: TUpdateKind; var Action: TReconcileAction);
begin
Action := HandleReconcileError(DataSet, UpdateKind, E);
end;
PROVIDERS
DATASET
var OwnerData: OleVariant);
begin
Log(‘TDataSetProvider.AfterExecute’);
end;
PROVIDERS
DATASET
procedure TfrmMain.DataSetProvider1UpdateError(Sender: TObject;
DataSet: TCustomClientDataSet; E: EUpdateError; UpdateKind: TUpdateKind;
var Response: TResolverResponse);
begin
Log(‘TDataSetProvider.OnUpdateError’);
end;
end.
Run two occurrences of this application on your computer. In the first occurrence, change
John’s first name to Eddie and apply updates. In the second occurrence, notice that the record 7
still shows a first name of John. Change John’s last name to Smith and apply updates. The
OnReconcileError event fires, indicating that the record has been changed by someone else in
PROVIDERS
DATASET
the meantime (see Figure 7.3).
FIGURE 7.3
Delphi’s reconcile error handler allows the user to decide how to deal with record conflicts.
By default, this dialog shows conflicting values only—in other words, fields that were changed
by another user. Uncheck the Show Conflicting Fields Only check box to display all field val-
ues. If you do, you’ll see that this application changed the LAST field from Lombardo to Smith.
Another application changed the FIRST field from John to Eddie.
The upper-right corner of the dialog contains a list of option buttons that the user can use to
instruct Delphi how to deal with the error. The option buttons correspond to the raSkip,
raCancel, raCorrect, raRefresh, and raMerge values for TReconcileAction. No raAbort
option is listed, but clicking the Cancel button will result in a value of raAbort being returned
from the event handler.
Chapter 7
290
Like several of the other sample applications presented in this book, the Updates application
contains an event log list box that shows you when and in what order interesting events fire for
both the dataset provider and the client dataset. These events are discussed in the section titled
“Provider Events,” later in this chapter.
CAUTION
Refresh will raise an exception if the dataset’s change log is not empty. Before you
call TClientDataSet.Refresh directly, you should check the ChangeCount property to
see if it is zero. If it is nonzero, either apply or cancel the updates first (using
ApplyUpdates or CancelUpdates) or refrain from calling Refresh.
Refreshing data from the provider is useful in those cases in which a reconciliation error
occurs and you want to retrieve the latest data from the underlying database. You can also call
it at other times to ensure that the local copy of the data is up to date.
Dataset Providers
291
Rather than calling Refresh to refresh the entire dataset, you can also call
TClientDataSet.RefreshRecord to refresh only the current record. RefreshRecord does not
raise an exception if the dataset’s change log is not empty. Rather, it leaves the entire change
log intact, including any changes that may have been made to the refreshed record.
NOTE
RefreshRecord requires that pfInKey be set for all fields in the primary key (as dis-
cussed in the following section). In addition, you should usually call RefreshRecord
only when the UpdateStatus of the current record is usUnmodified. 7
if ClientDataSet1.UpdateStatus = usUnmodified then
PROVIDERS
ClientDataSet1.RefreshRecord;
DATASET
Update Modes
When resolving data to the database, TDataSetProvider automatically builds the necessary
SQL statement to send to the server to perform the update. An example of such an SQL state-
ment is
UPDATE CONTACTS SET LAST = ‘Smith’ WHERE ID = 5
TDataSetProvider allows you some control over how the SQL statement is built. The first
level of control is afforded by the TDataSetProvider.UpdateMode property. UpdateMode may
be set to one of the values shown in Table 7.3.
Value Description
upWhereAll All designated fields are included in the WHERE clause.
upWhereChanged Only key fields as well as modified fields are included in the WHERE
clause.
upWhereKeyOnly Only key fields are included in the WHERE clause.
Assume a table named EMPLOYEES has four columns: ID, NAME, BIRTHDAY, and SALARY. ID is the
primary key for the table. One particular record in the table contains the values 1, “John
Smith”, 5/1/1958, $40,000.
The following SQL statements show what SQL statement would be generated for the various
settings of UpdateMode, assuming John Smith’s SALARY field was changed from $40,000 to
$45,000.
Chapter 7
292
Table 7.3 describes designated fields and key fields. So what constitutes a designated field or
key field, anyway? Each persistent field created on the server data module has a
ProviderFlags property, which is used to instruct the provider how to treat the field.
ProviderFlags is a set property and can contain any or all the values listed in Table 7.4.
ProviderFlags is a property of TField, which means you don’t need to have persistent fields
to use ProviderFlags. You can set ProviderFlags for a nonpersistent field, like this:
sqlClients.FieldByName(‘ID’).ProviderFlags := [pfInWhere, pfInKey];
Value Description
pfInUpdate The field can be modified.
pfInWhere The field is included in the WHERE clause when UpdateMode is set to
upWhereAll or upWhereChanged.
pfInKey The field is part of the primary key and controls such features as
refreshing records through a call to RefreshRecord, as well as recon-
ciliation options such as merging.
pfHidden The field is included in data packets sent to and from the client only to
serve as a way to make each record unique. The client dataset can’t see
or modify the field.
NOTE
Nonpersistent fields also have a ProviderFlags property, which is automatically set to
[pfInUpdate, pfInWhere].
Continuing with the previous EMPLOYEES table example, you would set up persistent fields for
the table as shown in Table 7.5.
Dataset Providers
293
TABLE 7.5 ProviderFlags Settings for the Fictitious EMPLOYEES Table Fields
Field ProviderFlags
ID [pfInUpdate, pfInWhere, pfInKey]
NAME [pfInUpdate, pfInWhere]
BIRTHDAY [pfInUpdate, pfInWhere]
SALARY [pfInUpdate, pfInWhere]
PROVIDERS
clause of an update SQL statement when the provider’s UpdateMode property is set to
DATASET
upWhereAll.
Provider Options
TDataSetProvider supports various options that determine the way in which data is sent to the
client dataset, what changes are allowed to the data, and the way in which updates to the data
are handled. TDataSetProvider.Options is a set property that enables you to customize these
options to your liking.
Table 7.6 shows the valid settings for the Options property.
Option Description
poFetchBlobsOnDemand When True, BLOBs are not returned from the server as
part of the data packet. The client application must call
TClientDataSet.FetchBlobs to retrieve BLOB data.
When False, BLOBs are returned as part of the data
packet.
poFetchDetailsOnDemand Used when the provider is part of a master/detail relation-
ship. When True, detail records are not returned from the
server as part of the data packet. The client application
must call TClientDataSet.FetchDetails to retrieve detail
records. When False, detail records are returned as part of
the data packet.
poIncFieldProps When True, field properties including Alignment,
Currency, DisplayFormat, DisplayLabel, DisplayValues,
DisplayWidth, EditFormat, EditMask, MaxValue,
MinValue, and Visible are sent to the client along with the
data.
Chapter 7
294
At the time of this writing, the poAutoRefresh option is not implemented. poAutoRefresh will
be useful for refreshing an updated record from the database when the database fills in field
values automatically.
Dataset Providers
295
For example, a database might include a trigger for autogenerating a table’s primary key, using
a generator. At the time a new record is posted to the database, the value of that field may be
NULL. During the post operation, the database will fill in the value of the primary key, and
poAutoRefresh will then reread the record so the Delphi application knows the value of the
primary key.
Without the poAutoRefresh option, you can still autoassign primary key values to a record, but
you will need to perform a little work in the server-side data module. This technique is
explained later, in the section titled “Changing Field Values on the Server.”
7
Provider Events
PROVIDERS
publishes two types of events—OnXxx and BeforeXxx/AfterXxx. The
DATASET
TDataSetProvider
BeforeXxx/AfterXxx events fire before and after “interesting” things happen in the provider,
such as when updates are applied to the underlying database.
Table 7.7 lists the Before and After events supported by TDataSetProvider.
Event Description
AfterApplyUpdates Fired after the updates to the database are complete.
AfterExecute Fired after the server executes the query or stored procedure
that will ultimately return data to the client.
AfterGetParams Fired after the server returns output parameters from the
dataset to the client dataset.
AfterGetRecords Fired after the provider creates the data packet to send to the
client.
AfterRowRequest Fired after the provider refreshes the current record because of
a call to TClientDataSet.RefreshRecord or any other
method that fetches data.
AfterUpdateRecord Fired after a record is successfully updated.
BeforeApplyUpdates Fired before updates are applied to the database.
BeforeExecute Fired before the server executes a query or stored procedure.
BeforeGetParams Fired before the server returns output parameters from the
dataset to the client dataset.
BeforeGetRecords Fired before the provider creates the data packet to send to the
client.
Chapter 7
296
Most of the BeforeXxx and AfterXxx events pass a parameter named OwnerData. OwnerData is
a variant that contains user-defined data. The data is passed from the client dataset to the
provider and back to the client dataset during certain method calls, such as ApplyUpdates. The
flow is as follows:
In the client dataset BeforeXXX event (such as BeforeGetRecords), OwnerData can be set to
anything that you want to pass to the provider. Because it is a variant, it can contain a simple
value such as an integer—or something more complex, such as an array of values.
As flow passes to the provider, the OwnerData parameter is passed to the provider’s BeforeXxx
event. The value of OwnerData may be changed in the BeforeXxx event, if needed.
Next, flow continues to the provider’s AfterXxx event, where OwnerData may again be
changed if necessary.
Finally, flow passes to the client dataset’s AfterXxx event, when you may inspect the (possibly
modified) value of OwnerData. The client dataset’s AfterXxx event is the end of the line, so
there is no reason to modify the value of OwnerData in the client dataset’s AfterXxx event.
The following chapter shows an example of how you can use the OwnerData parameter to
implement a stateless server. OnXxx events are fired to allow the application code to “hook”
into the various stages of providing data to the client and resolving it back to the server.
Table 7.8 lists the events supported by the TDataSetProvider component, along with their use.
Event Description
OnGetData Fired after data is fetched from the underlying database but
before the data is returned to the client. You can handle this
event to modify the data in some way before passing it on
to the client. For example, you might encrypt fields, com-
press the data, or weed out certain data that the client
should never see. This event is discussed in the
“Intercepting Data” section later in this chapter.
Dataset Providers
297
PROVIDERS
Data from a Join,” later in this chapter.
DATASET
OnUpdateData OnUpdateData is the counterpart of OnGetData. It is fired
just before updates are sent to the database server. You can
handle this event to decrypt data before it is saved to the
database, for example. This event is discussed in the
“Intercepting Data” section later in this chapter.
OnUpdateError Fired when an error occurs while reconciling data. If you
don’t handle this event, the error is sent back to the client
application. You can handle this event to ignore certain
errors or attempt to correct them on the server before send-
ing them back to the client.
Some of the more interesting events will be discussed in more detail in the sections that
follow.
In the BeforeUpdateRecord event handler for a newly inserted record, obtain the next value of
the ID field and assign it to the record. The following code snippet shows a typical implemen-
tation for the BeforeUpdateRecord event handler.
procedure TfrmMain.DataSetProvider1BeforeUpdateRecord(Sender: TObject;
SourceDS: TDataSet; DeltaDS: TCustomClientDataSet;
UpdateKind: TUpdateKind; var Applied: Boolean);
begin
if UpdateKind = ukInsert then
if DeltaDS.FieldByName(‘ID’).OldValue <= 0 then
DeltaDS.FieldByName(‘ID’).NewValue := GetNextID;
end;
sqlID is a TSQLDataSet component that executes a stored procedure on the database server,
which in turn returns the next unique ID number in an output parameter named AValue.
I won’t provide a program example using this technique at this time, but you’ll see this tech-
nique implemented in later examples in this chapter.
Intercepting Data
Table 7.4 lists two events, OnGetData and OnUpdateData, that can be used to intercept data on
its way from the provider to the client and also on its way from the client to the provider.
In this chapter, we’re working with a single application; in other words, the client and server
portions of the data are both contained in a single application. In the next chapter, we’ll create
client and server applications that may exist on separate machines that can be located down the
hall from each other or on different continents.
When data travels from machine to machine, unfortunately there is always the chance that
some hacker may be attempting to listen in on the exchange of data. If the data includes any-
thing sensitive, such as account numbers or the like, you might want to consider encrypting
that data before sending it over the wire or across cyberspace.
OnGetData and OnUpdateData provide the two hooks on the server side for implementing this
functionality. The following code snippet shows implementations of both OnGetData and
OnUpdateData that encrypt data on its way to the client and decrypt data on its way back. The
functions EncryptData and DecryptData referenced by the code are fictitious routines that you
would need to supply if you were to implement this functionality in your own applications.
Dataset Providers
299
NOTE
For a good third-party encryption library that supports encryption standards such as
DES, Blowfish, and Rijndael (AES), take a look at TurboPower Software’s LockBox
product at www.tpx.turbopower.com/products/LockBox.
PROVIDERS
DATASET
DataSet.FieldByName(‘AccountNumber’).AsString :=
EncryptData(DataSet.FieldByName(‘AccountNumber’).AsString);
DataSet.Post;
DataSet.Next;
end;
end;
Optional Parameters
Optional parameters are custom data that pertain to the dataset passed to the client. Optional
parameters relate to the dataset as a whole, rather than to individual records. You can use an
optional parameter to pass data back to the client, such as the date and time the data was pro-
vided, the length of time required to run the query on the server, or any other data.
To pass optional parameters to the client, provide an event handler for TDataSetProvider’s
OnGetDataSetProperties event. Within this event handler, use the Properties parameter to
set the values to send back to the client. The following code snippet shows how to send both
the time required to execute the query and the time the query was executed.
procedure TForm1.DataSetProvider1GetDataSetProperties(Sender: TObject;
DataSet: TDataSet; out Properties: OleVariant);
begin
Properties := VarArrayCreate([0, 1], varVariant);
Properties[0] := VarArrayOf([‘TimeQueried’, Now, True]);
Properties[1] := VarArrayOf([‘QueryPerformance’, FTimeToQuery, True]);
end;
Properties is a variant array of variant arrays. This code snippet creates an array of two vari-
ants. Each variant in the array is an array of three values: the name of the optional parameter,
the value of the parameter, and a Boolean value that indicates whether the optional parameter
should be sent back to the server as part of the delta.
In this example, FTimeToQuery is a private variable that is calculated using the following code:
procedure TForm1.SQLDataSet1BeforeOpen(DataSet: TDataSet);
begin
FTimeToQuery := GetTickCount;
end;
Master/Detail Relationships
Back in Chapter 2, “dbExpress Datasets,” you learned how to create a master/detail relation-
ship between two or more dbExpress datasets.
Later, in Chapter 4, you learned how to create nested TClientDataSets to create a
master/detail relationship at the TClientDataSet level. 7
Using providers, you will establish the master/detail relationship on the server-side data mod-
PROVIDERS
DATASET
ule, using TSQLDataSet components. You will then connect a TDataSetProvider component to
the master dataset only. Drop a single TClientDataSet component on the client-side data mod-
ule and connect it to the master dataset’s provider. This will automatically create a nested
dataset on the client side.
It is good practice to create one data module for the dbExpress components and dataset
providers and another data module for the client datasets. I refer to these data modules as
server-side data modules and client-side data modules. This is explained more fully in the sec-
tion titled, “Connecting to a Local Database,” later in this chapter.
The MasterDetail sample application, included in the downloads for this book, illustrates this
technique. I haven’t included a listing here, because almost no code is required—everything is
done at design time.
Figure 7.4 shows the main form of the MasterDetail application at design time. For simplicity,
I put the database components directly on the main form instead of creating separate data
modules for the server and client.
FIGURE 7.4
Components needed for a master/detail relationship.
Chapter 7
302
or
// Join
SELECT CONTACTS.FIRST, CONTACTS.LAST, TODOS.SCHEDULED, TODOS.DESCRIPTION
FROM CONTACTS, TODOS
WHERE CONTACTS.ID = TODOS.CONTACTID;
In addition, you need to set the ProviderFlags to [] for all fields in the stored procedure
dataset that are not updated.
If this is the case, you can specify the TODOS table name in the OnGetTableName event handler,
as explained earlier.
Sometimes, though, the user will be able to update multiple tables at once. To handle this situ-
ation, you will need to write some code. Again, we turn to TDataSetProvider’s
BeforeUpdateRecord event handler. The trick is to apply the necessary updates to the individ-
ual tables ourselves inside the event handler.
The following code snippet shows the general outline that you will follow to apply updates to
multiple tables at once. It is not compilable code.
procedure TForm1.SQLClientDataSet1BeforeUpdateRecord(Sender: TObject;
7
SourceDS: TDataSet; DeltaDS: TCustomClientDataSet; UpdateKind: TUpdateKind;
PROVIDERS
var Applied Boolean);
DATASET
var
SQL: string;
Connection: TSQLConnection;
begin
// Obtain a pointer to the connection from the source dataset
Connection := (SourceDS as TCustomSQLDataSet).SQLConnection;
case UpdateKind of
ukInsert: begin
// Insert into the first table
SQL := // SQL INSERT STATEMENT FOR TABLE 1
Connection.Execute(SQL, nil, nil);
ukModify: begin
// Update the first table
SQL := // SQL UPDATE STATEMENT FOR TABLE 1
Connection.Execute(SQL, nil, nil);
ukDelete: begin
// Delete from the first table
SQL := // SQL DELETE STATEMENT FOR TABLE 1
Connection.Execute(SQL, nil, nil);
Chapter 7
304
Applied := True;
end;
As you can see from the preceding listing, the general idea is to determine what kind of opera-
tion is taking place (Insert, Modify, or Delete) and then call the TSQLConnection component to
execute the appropriate SQL statements directly. At the end of the method, set Applied to True
so the provider knows that you’ve already handled the update manually.
This process works equally well for three-, four-, or n-table joins.
The following example shows how you can resolve updates in a simple two-way join.
Listing 7.2 contains the source code for the main form of the Joins application.
interface
uses
SysUtils, Types, Classes, QGraphics, QControls, QForms, QDialogs,
QStdCtrls, DBXpress, FMTBcd, DB, SqlExpr, QGrids, QDBGrids, Provider,
DBClient, Variants;
type
TfrmMain = class(TForm)
DataSource1: TDataSource;
DBGrid1: TDBGrid;
SQLConnection1: TSQLConnection;
SQLDataSet1: TSQLDataSet;
DataSetProvider1: TDataSetProvider;
ClientDataSet1: TClientDataSet;
sqlID: TSQLDataSet;
btnApplyUpdates: TButton;
procedure FormCreate(Sender: TObject);
procedure DataSetProvider1BeforeUpdateRecord(Sender: TObject;
SourceDS: TDataSet; DeltaDS: TCustomClientDataSet;
UpdateKind: TUpdateKind; var Applied: Boolean);
procedure btnApplyUpdatesClick(Sender: TObject);
procedure ClientDataSet1NewRecord(DataSet: TDataSet);
Dataset Providers
305
var
frmMain: TfrmMain; 7
implementation
PROVIDERS
DATASET
{$R *.xfm}
case UpdateKind of
ukInsert: begin
ID := GetNextID;
Chapter 7
306
ukModify: begin
// Update the first table
SQL := ‘’;
if VarIsNull(DeltaDS.FieldByName(‘SPOUSE’).OldValue) then
SQL := Format(‘INSERT INTO CONTACTS2 (CONTACTID, SPOUSE) ‘ +
‘VALUES (%d, %s)’,
Dataset Providers
307
PROVIDERS
DATASET
// Delete from the second table
SQL := Format(‘DELETE FROM CONTACTS2 WHERE CONTACTID = %d’, [ID]);
Connection.Execute(SQL, nil, nil);
Applied := True;
end;
end.
The CONMAN database contains a CONTACTS2 table, which has a one-to-one correspondence to
the CONTACTS table. CONTACTS2 is only in the database for purposes of this example.
The DataSetProvider1BeforeUpdateRecord method handles all insert, modify, and delete
requests by dynamically building the appropriate SQL statements and sending them directly to
the database.
To keep the SQL statements simple, this sample application works with just two fields from
the CONTACTS table and a single field from the CONTACTS2 table.
Chapter 7
308
PROVIDERS
DATASET
nents on the server side and then replace the TSQLClientDataSet component with a
TClientDataSet component on the client side. In addition, TSQLClientDataSet limits the
amount of control you have over individual provider and dataset events.
It doesn’t require much more effort to set up the separate components to begin with, and it
makes migration to a multitier application much easier in the future. For that reason, I don’t
recommend using TSQLClientDataSet at all in most applications.
NOTE
If your client application has no need for BLOB data at all, you should change the
TSQLDataset’s CommandText property so that the SELECT statement doesn’t retrieve
the BLOB field from the database. poFetchBlobsOnDemand is used when the client
application sometimes needs the BLOB data, but not always.
When you set the poFetchBlobsOnDemand option, BLOB data is not returned to the client
dataset. If you try to access a BLOB field in the client dataset, an exception will be raised. If
the client needs BLOB information for the current record, it should call the client dataset’s
FetchBlobs method, like this:
ClientDataSet1.FetchBlobs;
FetchBlobs retrieves all BLOB fields for the current record only. If the dataset contains
three BLOB fields, there is no way to retrieve only a single BLOB value from the server—
FetchBlobs will return all three BLOBs.
Note that if the client dataset’s FetchOnDemand property is set to True, the client dataset will
call FetchBlobs automatically. To fetch BLOBs manually, you must set both the
poFetchBlobsOnDemand option on the provider and the client dataset’s FetchOnDemand
property.
ClientDataSet1.FetchDetails;
Dataset Providers
311
As with fetching BLOBs, you must set the client dataset’s FetchOnDemand property to False,
or the client dataset will automatically call FetchDetails, even if the
poFetchDetailsOnDemand option is set on the provider.
FetchDetails retrieves all detail fields for the current record only. If the dataset contains three
nested datasets, there is no way to retrieve details for only a single nested dataset—
FetchDetails will return records for all three nested datasets.
The following sample application illustrates how you can use FetchBlobs and FetchDetails
to limit the amount of data returned from the server.
7
Listing 7.3 shows the source code for the main form of the DataFetch application. To simplify
the sample somewhat, all components are on the main form of the application. In a real appli-
PROVIDERS
DATASET
cation, you should create separate server-side and client-side data modules for the data access
components.
interface
uses
SysUtils, Types, Classes, QGraphics, QControls, QForms, QDialogs,
QStdCtrls, DBXpress, FMTBcd, Provider, SqlExpr, DB, DBClient, QGrids,
QDBGrids, QDBCtrls;
type
TfrmMain = class(TForm)
cdsContacts: TClientDataSet;
conn: TSQLConnection;
sqlContacts: TSQLDataSet;
pvContacts: TDataSetProvider;
sqlTodos: TSQLDataSet;
dsContacts: TDataSource;
dsClientContacts: TDataSource;
gridContacts: TDBGrid;
btnFetchBlobs: TButton;
btnFetchDetails: TButton;
cdsTodos: TClientDataSet;
cdsContactsID: TIntegerField;
cdsContactsFIRST: TStringField;
cdsContactsLAST: TStringField;
cdsContactsDEAR: TStringField;
cdsContactsTITLE: TStringField;
cdsContactsCOMPANYNAME: TStringField;
Chapter 7
312
var
frmMain: TfrmMain;
implementation
{$R *.xfm}
PROVIDERS
DATASET
var
TestStream: TStream;
begin
try
TestStream := cdsContacts.CreateBlobStream(cdsContactsNOTES, bmRead);
TestStream.Free;
lblBLOBs.Caption := ‘BLOBs have been fetched for this record’;
except
lblBLOBs.Caption := ‘BLOBs have not been fetched for this record’;
end;
case cdsTodos.RecordCount of
-MaxInt - 1:
lblDetails.Caption := ‘Todos have not been fetched for this record’;
1:
lblDetails.Caption := ‘1 todo has been fetched for this record’;
else
lblDetails.Caption := IntToStr(cdsTodos.RecordCount) +
‘ todos have been fetched for this record’;
end;
end;
end.
A few interesting things are going on in this application. Let’s take a look at the methods one
at a time.
First, however, let’s examine the structure of the application.
Figure 7.5 shows the main form of the DataFetch application at design time.
Chapter 7
314
FIGURE 7.5
DataFetch main form showing relationships between components.
The FormCreate method opens the cdsContacts client dataset. This causes a chaining effect
that opens the sqlContacts dbExpress dataset and sends the results back to the client through
the pvContacts provider.
Two buttons on the form, labeled Fetch Details and Fetch Blobs, call the
btnFetchDetailsClick and btnFetchBlobsClick events, respectively. These two events, in
turn, call the client dataset’s FetchDetails and FetchBlobs methods. They then call the
ShowTodoCount method, which we’ll examine shortly.
To check whether details have been fetched for the current contact, the code looks at
cdsTodos.RecordCount. RecordCount will be –MaxInt – 1 if details have not yet been fetched.
Figure 7.6 shows the main form of the DataFetch application at runtime.
PROVIDERS
DATASET
FIGURE 7.6
DataFetch shows how to fetch details and BLOBs on demand.
Summary
This chapter introduced you to providers and multitier database development, including the
following key concepts:
• A TDatasetProvider is a conduit between a client dataset and an external data store. It
provides data to the client dataset on request and resolves data back to the database when
the client applies updates.
• You need to call the client dataset’s ApplyUpdates method to save changes to a database
permanently.
• For greatest control over reconciliation errors, you should provide an event handler for
the client dataset’s OnReconcileError event. Delphi comes with a prewritten function,
named HandleReconcileError, that you can use to facilitate this process.
• By calling TClientDataSet.Refresh, you can ensure that the client always has the latest
copy of the data from the server.
• You can set the provider’s update mode to upWhereAll, upWhereChanged, or
upWhereKeyOnly for finer control over how each record is updated.
• You can take advantage of numerous provider options and events for finer control over
the entire provide/resolve process.
• When creating a master/detail relationship on the server-side components, the client
dataset will represent that relationship as a nested dataset.
Chapter 7
316
• By handling the provider’s OnGetTableName event, you can update data returned from a
stored procedure.
• By handling the provider’s BeforeUpdateRecord event, you can update data returned
from a join operation.
• You can call TClientDataSet.SetProvider at runtime to establish a connection between
client datasets and providers on different forms or data modules.
• To replace separate TSQLDataSet, TDataSetProvider, and TClientDataSet components,
use TSQLClientDataSet.
• To limit the amount of data returned by the server at a single time, set the provider’s
poFetchBlobsOnDemand and/or poFetchDetailsOnDemand options, and then call
TClientDataSet.FetchBlobs or TClientDataSet.FetchDetails manually.
The next chapter expands on this discussion to show you how to create a multitier application
with separate server and client executables.
DataSnap CHAPTER
8
IN THIS CHAPTER
• What Is DataSnap? 318
The previous chapter gave an overview of providers and explained how to use them in a multi-
tier application where the client and application server are physically located in a single exe-
cutable.
This chapter expands the discussion to show you how to create multitier database applications
where the client and application server are in different executables, which may then run on the
same machine or, more likely, on two separate computers.
Delphi supports a number of underlying protocols to connect to an application server, includ-
ing DCOM, CORBA, HTTP, and SOAP. This book does not attempt to explain any of these
technologies in detail. Rather, it describes how to set up the required connection component for
each of the technologies. It is assumed that you already have the necessary software installed
and functioning properly for the protocol that you will use to connect to the application server.
What Is DataSnap?
DataSnap is the technology that allows client applications to connect to providers in an appli-
cation server. DataSnap is implemented by a number of components that can be used to con-
nect different machines through such underlying technologies as sockets, DCOM, CORBA,
HTTP, and SOAP.
NOTE
In earlier versions of Delphi, DataSnap was named MIDAS. Because of international
trademark considerations, the name MIDAS has been changed to DataSnap.
In many, if not most, application servers, a single remote data module will suffice. However,
you can create multiple remote data modules in a single application server if you want. Some
reasons for doing so include the following:
• The application server needs to connect to multiple databases, and you want to create a
distinct remote data module for each database connection.
• Two types of users will run this application—perhaps a “normal” user and an administra-
tor. You may want to create two separate remote data modules, in which the remote data
module used for administrators contains additional tables or queries for sensitive data.
• One data module is used to provide access to the underlying database, and another data
module is used to provide other, non-data-aware services, such as numeric calculations.
This capability is discussed later, in the section titled “Adding Methods to the Remote
Data Module.”
DATASNAP
in Figure 8.1.
• Select one of the remote data modules from the list and click OK. The different data
modules are discussed in the following sections.
FIGURE 8.1
Delphi supports three remote data modules—Remote Data Modules, Transactional Data Modules, and CORBA Data
Modules.
Chapter 8
320
FIGURE 8.2
Creating a standard remote data module.
Enter a CoClass name for the data module, such as ContactDataServer or some other mean-
ingful name. The application name and CoClass together form the name by which the remote
data module is referenced. For example, if the application name is ContactServer and the
CoClass name is ContactDataServer, the remote data module is referenced from the client
application as ContactServer.ContactDataServer.
Select the type of instancing to use for the remote data module. Table 8.1 lists the possible val-
ues for this selection.
Value Description
Internal The remote data module cannot be created from an external
client. Internal remote data modules are always created from
within the server application.
Single Instance Each client that attempts to connect to the server will cause a
separate instance of the server executable to run.
Multiple Instance Only one copy of the server executable will run, but it will
instantiate a separate remote data module for each client that
connects to it.
In most situations, you should leave the Instancing combo box set to Multiple Instance.
Lastly, select the threading model to use for the remote data module. Table 8.2 lists the possi-
ble values for the Threading Model combo.
DataSnap
321
DATASNAP
Threading Model are fine in most cases) you can click OK to create the remote data module.
FIGURE 8.3
Creating a transactional remote data module.
Chapter 8
322
Enter a CoClass name for the data module, such as ContactDataServer, or some other mean-
ingful name.
Select the threading model to use for the remote data module. Transactional data modules sup-
port the Single, Apartment, and Both threading models from Table 8.2.
Finally, select the transaction model to use for the data module. Because we won’t be creating
TMTSDataModules in this book, I’ll refer you to the Delphi help for an explanation of the differ-
ent transactional models.
Click OK to create the TMTSDataModule.
FIGURE 8.4
Creating a CORBA remote data module.
Enter a CoClass name for the data module, such as ContactDataServer, or some other mean-
ingful name.
Select the instancing type and threading models to use for the remote data module. Because
we won’t be creating TCORBADataModules in this book, I’ll refer you to the Delphi help for an
explanation of the valid instancing types and threading models for a CORBA data module.
Click OK to create the TCORBADataModule.
To create a remote data module for a SOAP application server, perform the following steps.
• From Delphi’s main menu, select File, New, Other. Delphi’s New Items dialog is dis-
played. Select the WebServices tab.
• Select SOAP Server Application from the New Items dialog and click OK. The dialog
shown in Figure 8.5 appears.
FIGURE 8.5
Delphi supports five types of SOAP server applications.
• Select the type of SOAP server application to create. If you’re creating a Web App 8
Debugger executable, enter a CoClass name to use for the COM object that the Web App
DATASNAP
Debugger uses to call your Web module. Click OK to create the SOAP Server
Application.
• Again, select File, New, Other from Delphi’s main menu. Select the WebServices tab in
the New Items dialog.
• Select SOAP Server Data Module on the WebServices tab and click OK. The dialog
shown in Figure 8.6 appears.
FIGURE 8.6
Creating a SOAP Server Data Module.
• Enter a class name for the data module and click OK. The class name may be anything
you want, such as MyDataServer, CustomerDataServer, or any other meaningful name.
Chapter 8
324
FIGURE 8.7
Adding a method to a remote data module.
When you click OK, Delphi writes an empty method for you, using the declaration you just
entered. At this point, you need to flesh out the method call. The following code snippet shows
the simple GetServerTime method created in Figure 8.7.
function TMethodsDM.GetServerTime: TDateTime;
begin
Result := Now;
end;
Later in this chapter, you’ll see how to call this method from a client application.
DataSnap
325
Callbacks
In addition to receiving calls from a client, the server application can also make calls to a
client application, using a callback interface.
NOTE
This section assumes that you have some knowledge of interfaces, what they are, and
how to use them. If you do not have this knowledge, I suggest picking up a copy of
my book, Delphi COM Programming.
To create a callback interface, select View, Type Library from the Delphi main menu. The type
library editor appears, shown in Figure 8.8.
DATASNAP
FIGURE 8.8
Creating a callback interface.
Click the New Interface button (the first button on the toolbar) and name the new interface
something original like ITestCallback. Now click the New Method toolbar button, and name
the new method Test. To keep things simple, Test won’t take any parameters or return a
result.
Close the type library editor.
Now that the server application knows about ITestCallback, you’ll need a way for the client
to pass an ITestCallback interface to the server. This is accomplished by creating a method
on the server that takes a parameter of type ITestCallback and saves the value in a variable
local to the remote data module, like this:
Chapter 8
326
type
TMethodsDM = class(TRemoteDataModule, IMethodsDM)
...
private
{ Private declarations }
FCallback: ITestCallback;
...
end;
...
procedure TMethodsDM.SetCallback(const Callback: ITestCallback);
begin
FCallback := Callback;
end;
The client application will call this method at some point to set up a callback interface with the
server. The server can then make calls to the interface later.
This technique is illustrated in the Methods sample application, presented later in this chapter
in the section titled “A Complete Example.”
NOTE
Callbacks have a couple of limitations with certain types of connections (discussed
later in this chapter, in the section titled “Creating the Client Application”). First,
when using a socket connection, you must make sure to set
TSocketConnection.SupportsCallbacks to True. In addition, the callback interface
must derive from IDispatch. Second, TWebConnection and TSOAPConnection don’t
support callbacks at all, so keep that in mind if you intend to use HTTP as the commu-
nication protocol between the client and server applications.
FIGURE 8.9
Application server with minimal user interface.
To create this interface, all you need to do is notify the main form each time a remote data
module is created or destroyed. A reasonable place to do this is in the data module’s OnCreate
and OnDestroy event handlers.
Listing 8.1 shows the source code from the main form of the MethodsServer application server
(presented a little later in this chapter). This code is typical of the main form of an application
server.
unit MainForm;
interface 8
uses
DATASNAP
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls;
const
UM_CONNECT = WM_USER + 1;
type
TfrmMain = class(TForm)
lblConnections: TLabel;
private
{ Private declarations }
FConnections: Integer;
procedure UpdateConnections;
procedure UMConnect(var Msg: TMessage); message UM_CONNECT;
public
{ Public declarations }
end;
var
frmMain: TfrmMain;
implementation
Chapter 8
328
{ TfrmMain }
procedure TfrmMain.UpdateConnections;
begin
if FConnections = 1 then
lblConnections.Caption := ‘1 connection’
else
lblConnections.Caption := IntToStr(FConnections) + ‘ connections’;
end;
end.
The remote data module then calls the main form’s Connect and Disconnect methods on cre-
ation and destruction, as the following code snippet shows.
procedure TMethodsDM.RemoteDataModuleCreate(Sender: TObject);
begin
PostMessage(frmMain.Handle, UM_CONNECT, 1, 0); end;
After the application server is debugged locally, you should then move it to a server machine
for further testing and debugging.
For application servers implementing TRemoteDataModule, registering the server is a simple
matter of running it once. Running the server automatically registers it with the computer.
When you modify the application server or move it to a new directory or computer for testing,
you should run it again.
Other remote data modules, such as TMTSDataModule or TCORBADataModule, will require regis-
tering the server with MTS or CORBA.
DATASNAP
select Application (at this time, CLX applications don’t support DataSnap).
Now create a data module, which will house the client datasets that retrieve data from the
application server.
3. In the client-side data module, set the client dataset’s RemoteServer property to the
TLocalConnection component.
4. In the client dataset’s ProviderName property, select the provider from the drop-down
list.
After you complete these steps, all you need to do is open the client dataset to pull the data
from the database into the client dataset.
The LocalConn application example, included with the source code for this book, shows the
correct way to set up an application that uses a local connection to access data. Because almost
no source code is in the application that isn’t generated by Delphi for you, I haven’t listed the
source code here.
Socket Connections
TSocketConnection uses sockets as a transport between the application server and client appli-
cation. It is the simplest of the connection protocols to set up and use and is the one that is
used in this book’s examples. No additional software or licensing fees are required when using
sockets (other than the standard DataSnap licensing issues, which are discussed in Appendix A
of this book). Also, sockets are supported by any machine that has a TCP/IP address.
The disadvantage of a socket connection is that sockets don’t provide very good support for
security. However, you can work around this limitation by writing a data packet interceptor,
discussed later in this chapter in the section titled “Intercepting Data Packets.”
In addition, sockets can’t always be used to connect to a server located behind a firewall
(unless the firewall is configured to allow access to the port that the socket server listens on,
which is discussed in the following section).
Table 8.3 lists the most important properties for TSocketConnection.
DataSnap
331
Property Description
Address Specifies the IP address of the remote machine to connect to. You
may specify the name of the remote machine instead of the IP
address by setting the Host property rather than the Address
property.
Host Specifies the name of the remote machine to connect to. You may
specify the IP address of the remote machine instead of the name
by setting the Address property rather than the Host property.
InterceptGUID Specifies the GUID of a COM object that intercepts data packets
sent between the client and the application server. You may spec-
ify the ProgID of the COM object rather than the GUID by set-
ting the InterceptName property instead of InterceptGUID. Data
interceptors are discussed in the section titled “Intercepting Data
Packets,” later in this chapter.
InterceptName Specifies the ProgID of a COM object that intercepts data pack-
ets sent between the client and the application server. You may
specify the GUID of the COM object rather than the ProgID by 8
setting the InterceptGUID property instead of InterceptName.
Data interceptors are discussed in the section titled “Intercepting
DATASNAP
Data Packets,” later in this chapter.
Port Specifies the port that the socket server uses to listen to socket
requests. By default, this is set to 211.
ServerGUID Specifies the GUID of the application server that the client will
connect to. You can set the server name using the ServerName
property instead of ServerGUID.
ServerName Specifies the name of the application server that the client will
connect to. You can set the server GUID using the ServerGUID
property instead of ServerName.
SupportCallbacks Set to True if your server supports callbacks to the client applica-
tion. If this property is True, WinSock 2 must be present on the
server machine. When False, the server machine does not
require WinSock 2 to operate properly, but it also doesn’t support
callbacks.
You must run the socket server on the remote machine before attempting to connect to an
application server using sockets. If you don’t run the socket server first, the attempted connec-
tion to the server will appear to hang and will eventually time out.
NOTE
You can install the socket server as a service on a Windows NT machine by
running scktsrvr –install at the command prompt. To remove the service, run
scktsrvr –uninstall. After installing as a service, you must either reboot your com-
puter or start the service manually before it will run the first time.
The Borland socket server usually sits inconspicuously in the tray. However, you can double-
click it to open the socket server, as Figure 8.10 shows.
FIGURE 8.10
Socket server enables you to set the port number and other properties for the socket server.
The default port on which the socket server listens is 211. Unless you have a good reason for
doing so, you should leave it set to the default value. If you change the port, you will also need
to change the TSocketConnection component in any client applications to use the same port.
The intercept GUID can be set to the GUID of a COM object used to intercept data packets
transferred between the client and server. The following section discusses this in more detail.
DataSnap
333
2. Register the COM object on both the server machine and all client machines.
3. Set the TSocketConnection’s ServerName property to the name of the application server.
4. Inside the socket server, set the Intercept Name to the same name.
5. Run the application as usual. 8
You should debug your application without the interceptor to make sure it works correctly
DATASNAP
before adding the interceptor into the mix.
DCOM Connections
A TDCOMConnection uses Distributed COM (DCOM) as its underlying protocol, tying it to the
Windows operating system. DCOM is a little tricky to set up properly, especially if the appli-
cation server isn’t running on a Windows domain server.
I assume at this point that if you’re going to use TDCOMConnection, you know how to set up
and use DCOM. If you need additional information on configuring DCOM, you can look in
my COM book or see Dan Miser’s Web page at www.distribucon.com.
Table 8.4 lists the most important properties for TDCOMConnection.
HTTP Connections
TWebConnection uses HTTP as its transport protocol, which makes it useful for writing appli-
cations that run over the Internet. HTTP offers some additional features over a socket connec-
tion so that you can take advantage of SSL security. Also, you can connect to a server
computer that is located behind a firewall.
When using a TWebConnection, you must be sure to redistribute the Web server application
(httpsrvr.dll) along with the server application. httpsrvr.dll is an ISAPI extension that brokers
the HTTP call from the client to the COM object on the server application. In addition, the
client machine must contain a copy of wininet.dll. Wininet.dll is included with Internet
Explorer version 3 and later.
Table 8.5 lists the most important properties for TWebConnection.
Property Description
Password Valid password used for authentication on the host machine. This may
be left blank if the host does not require authentication.
Proxy Semicolon-delimited list of proxy servers that can be used to resolve
the IP address of the host machine.
ServerGUID Specifies the GUID of the application server that the client will con-
nect to. You can set the server name using the ServerName property
instead of ServerGUID.
ServerName Specifies the name of the application server that the client will connect
to. You can set the server GUID using the ServerGUID property instead
of ServerName.
URL The URL used to locate httpsrvr on the host machine.
UserName Valid username used for authentication on the host machine. This may
be left blank if the host does not require authentication.
DataSnap
335
SOAP Connections
TSOAPConnection uses SOAP to communicate between the client and server applications.
TSOAPConnection is similar to TWebConnection in that it uses HTTP as the underlying
protocol.
As with TWebConnection, the client machine must contain a copy of wininet.dll.
Table 8.6 lists the most important properties for TSOAPConnection.
Property Description
Password Valid password used for authentication on the host machine. This may
be left blank if the host does not require authentication.
Proxy Semicolon-delimited list of proxy servers that can be used to resolve
the IP address of the host machine.
URL The URL used to locate the application server on the host machine.
UserName Valid username used for authentication on the host machine. This may
be left blank if the host does not require authentication. 8
DATASNAP
CORBA Connections
TCORBAConnection uses CORBA, or IIOP, as its underlying protocol. To establish a CORBA
connection to an application server, you must be running a CORBA Smart Agent somewhere
on your network.
Table 8.7 lists the most important properties for TCORBAConnection.
Property Description
HostName Specifies the machine where the application server is located. If this
property is blank, the connection component will connect to the first
available machine that supports the interface specified by the
RepositoryID property.
ObjectName Set this property if the interface specified by RepositoryID must be
implemented by a specific instance of the object. Leave this property
blank if only one object supports the specified interface.
RepositoryID Specifies the CORBA data module to connect to. May take one of two
forms:
IDL:ProjectName/CorbaDataModuleName:1.0
or
ProjectName/CorbaDataModuleName
Chapter 8
336
A Complete Example
The following example uses a TSocketConnection to connect to the application server
remotely. Also, it demonstrates how to call additional methods on the server and how the
server can use a callback interface to call methods on the client or fire events back to the client
application.
Listing 8.2 contains the source code for the server’s remote data module.
unit ServerDataModule;
interface
uses
Windows, Messages, SysUtils, Classes, ComServ, ComObj, VCLCom, DataBkr,
DBClient, MethodsServer_TLB, StdVcl, DBXpress, FMTBcd, DB, SqlExpr,
Provider, Variants;
type
TMethodsDM = class(TRemoteDataModule, IMethodsDM)
conn: TSQLConnection;
sqlContacts: TSQLDataSet;
pvContacts: TDataSetProvider;
procedure RemoteDataModuleCreate(Sender: TObject);
procedure RemoteDataModuleDestroy(Sender: TObject);
private
{ Private declarations }
FCallback: OleVariant;
protected
class procedure UpdateRegistry(Register: Boolean;
const ClassID, ProgID: string); override;
function GetServerTime: TDateTime; safecall;
procedure SetCallback(Callback: OleVariant); safecall;
procedure TestCallbacks; safecall;
public
{ Public declarations }
end;
implementation
DataSnap
337
{$R *.DFM}
DATASNAP
begin
Result := Now;
end;
procedure TMethodsDM.TestCallbacks;
var
Index: Integer;
begin
if not VarIsEmpty(FCallback) then
for Index := 1 to 3 do
FCallback.Test;
end;
initialization
TComponentFactory.Create(ComServer, TMethodsDM,
Class_MethodsDM, ciMultiInstance, tmApartment);
end.
Listing 8.3 contains the source code for the main form of the client application.
unit MainForm;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, DB, Grids, DBGrids, StdCtrls, MethodsServer_TLB, ComObj, ActiveX;
type
TTest = class(TAutoIntfObject, ITestCallback)
protected
procedure Test; safecall;
end;
TfrmMain = class(TForm)
DBGrid1: TDBGrid;
DataSource1: TDataSource;
btnGetServerTime: TButton;
btnTestCallbacks: TButton;
procedure FormCreate(Sender: TObject);
procedure btnGetServerTimeClick(Sender: TObject);
procedure btnTestCallbacksClick(Sender: TObject);
private
{ Private declarations }
FTest: TTest;
public
{ Public declarations }
end;
DataSnap
339
implementation
uses DataModule;
{$R *.dfm}
DATASNAP
procedure TfrmMain.btnTestCallbacksClick(Sender: TObject);
var
typelib: ITypeLib;
begin
OleCheck(LoadRegTypeLib(LIBID_MethodsServer, 1, 0, 0, typelib));
FTest := TTest.Create(typelib, ITestCallback);
DM.SocketConnection1.AppServer.SetCallback(FTest as IDispatch);
DM.SocketConnection1.AppServer.TestCallbacks;
end;
procedure TTest.Test;
begin
ShowMessage(‘In callback’);
end;
end.
Chapter 8
340
While on the road, the client is unable to connect to the application server; instead, it loads the
local copy of the data by calling TClientDataSet.LoadFromFile. Changes are accumulated in
the change log, which is persisted by calling TClientDataSet.SaveToFile before the user
exits the application.
When the user returns to the office, the client application again calls
TClientDataSet.LoadFromFile to load the local copy of the data, but then calls
TClientDataSet.ApplyUpdates to resolve any updates back to the application server.
Note that the server application does not change in any way—only the client application con-
tains code to support the briefcase model.
Because Delphi ships with a useful briefcase demo application, I won’t duplicate its function-
ality here. Instead, I encourage you to take a look at the Delphi demo. For a typical Delphi
installation, look at C:\Program Files\Borland\Delphi6\Demos\Midas\Brfcase.
NOTE
Rather than calling LoadFromFile and SaveToFile in your application, you can simply
assign the client dataset’s FileName property to the name of the local file to save to
and load from. Delphi will then take care of loading and saving the data at the
appropriate times.
DataSnap
341
Stateless Servers
In the last chapter, I showed you how you can limit the amount of data returned at a given time
from the application server. The catch with limiting the amount of data returned from the
server is that you must let the server know from where to start retrieving data each time it
fetches a new data packet. Delphi database applications are stateless, meaning that the server-
side database components don’t keep track of their current location in the dataset. When the
provider retrieves data from a TSQLDataSet or other dataset component, the dataset is opened,
data is sent to the provider, and then the dataset is closed. Because the dataset is accessed only
for a short period of time, a single server-side dataset can service multiple client applications,
reducing memory load and increasing performance. This isn’t much of an issue with a local
connection, but it becomes an important consideration when using remote connections.
Conceptually, incremental data fetching on a stateless server works like this:
• The provider fetches a set of records, remembering the ID of the last record in the batch.
• The provider notifies the client application of the last record.
• The client remembers the last record fetched.
• When the client fetches the next batch of records from the server, it notifies the server of 8
the last record previously retrieved.
DATASNAP
• The server retrieves the next batch of records that occurs after the last record previously
retrieved.
To implement a stateless server, you need to supply event handlers for three provider events on
the server, as well as for two client dataset events on the client. Let’s start with the server:
In the application server, you need to set up the TSQLDataSet as a parameterized query, like
this:
SELECT * FROM CONTACTS WHERE ID > :MinID ORDER BY ID
This will select only contacts with an ID greater than the last ID that has already been fetched.
Now, provide an event handler for the provider’s OnGetData event. In this event, you’ll save
the last ID retrieved from the contact table.
procedure TStatelessServerData.pvContactsGetData(Sender: TObject;
DataSet: TCustomClientDataSet);
begin
DataSet.Last;
pvContacts.Tag := DataSet.FieldByName(‘ID’).AsInteger;
end;
Chapter 8
342
The call to DataSet.Last does not operate directly on the TSQLDataSet component. Instead, it
moves to the last record of the batch of records that is about to be sent back to the client appli-
cation. The ID of that last fetched record is then saved temporarily in the Tag property of the
provider.
Next, write an event handler for the provider’s AfterGetRecords event handler. In this handler,
you’ll pass the last retrieved ID back to the client application via the OwnerData parameter.
procedure TStatelessServerData.pvContactsAfterGetRecords(Sender: TObject;
var OwnerData: OleVariant);
begin
OwnerData := pvContacts.Tag;
end;
Finally, on the server, you need to handle the provider’s BeforeGetRecords event handler. In
this event, you’ll set the value of the MinID parameter so that the query knows what record to
fetch next. The following code snippet shows how to accomplish this:
procedure TStatelessServerData.pvContactsBeforeGetRecords(Sender: TObject;
var OwnerData: OleVariant);
begin
if not VarIsEmpty(OwnerData) then begin
sqlContacts.Params.ParamValues[‘MinID’] := OwnerData;
sqlContacts.Refresh;
end;
end;
That’s all you need to do on the server. In the client application, you need to first provide an
event handler for the client dataset’s AfterGetRecords event. In this event handler, you’ll save
the last ID retrieved from the server, as the following code snippet illustrates:
procedure TForm1.cdsContactsAfterGetRecords(Sender: TObject;
var OwnerData: OleVariant);
begin
cdsContacts.Tag := OwnerData;
end;
In this example, the last retrieved ID (provided in the OwnerData parameter) is again stored in
the client dataset’s Tag property. You could also set up a form variable named FLastContactID
to save the ID.
Finally, provide an event handler for the client dataset’s BeforeGetRecords event. In this event
handler, you’ll pass the last retrieved ID back to the server so that it knows where to start
fetching data from.
DataSnap
343
The Stateless application example incorporates these concepts into a stateless server and client.
Because most of the source code was listed previously in the form of code snippets, I won’t
show it again here.
DATASNAP
connection to another means that you need to set the RemoteServer property for each
TClientDataSet component to the correct connection at runtime.
In small sample applications, this isn’t a big problem, because most examples use only one or
two client datasets. However, most real-world applications contain many more than that. For
example, I am currently working on an application that utilizes more than 50 TClientDataSet
components in the client application.
To facilitate switching from one connection component to another, Delphi provides a
TConnectionBroker component. To use a TConnectionBroker, drop one on the client’s data
modules and set the ConnectionBroker property for each TClientDataSet component to the
TConnectionBroker. Then, set TConnectionBroker’s RemoteServer property to the correct
connection component. Switching connections is then a trivial matter of changing the connec-
tion broker’s RemoteServer property to the desired connection component.
In addition to easily switching from connection to connection, you gain another advantage by
using a connection broker. If you make calls to the application server in your code, such as the
following:
SocketConnection1.AppServer.ExecuteSomeMethod;
Chapter 8
344
You don’t need to change that code if the connection type changes. Instead of calling the
method on SocketConnection1 (or any other connection component), you can call it on the
connection broker, like this:
ConnectionBroker1.Connection.AppServer.ExecuteSomeMethod;
Summary
This chapter discussed DataSnap, Delphi’s remote access technology for multitier database
development.
• The first step in creating a separate application server is to set up one or more remote
data modules. Delphi supports the creation of standard, MTS, CORBA, and SOAP
remote data modules.
• To create methods for use with an application server, use the Add to Interface menu item.
To create a callback interface, use the Type Library editor to create a new interface.
• To create a simple user interface for the application server, post a custom message to the
main form whenever a remote data module is created or destroyed. A reasonable place to
do this is in the data module’s OnCreate and OnDestroy event handlers.
8
• You can connect to the application server from a client application, using a variety of
DATASNAP
connection protocols, including TSocketConnection, TDCOMConnection,
TWebConnection, TSOAPConnection, and TCORBAConnection.
• The briefcase model allows your application to download data to the local machine for
use when it is impossible to connect to the server.
• To facilitate the use of multiple application servers in a large-scale application, Delphi
provides the TConnectionBroker component.
The next chapter introduces a complete application that uses many of the techniques discussed
in this chapter and throughout the rest of the book.
The ConMan Application CHAPTER
9
IN THIS CHAPTER
• What Is ConMan? 348
In this book, I’ve discussed quite a number of techniques for writing database applications in
Delphi and Kylix. Along the way, I’ve presented a number of small sample applications to
solidify many of the important points discussed in each chapter.
In this chapter, I’ll present a complete multiuser database application that incorporates many of
the ideas discussed in this book. The application is called ConMan (short for Contact
Manager).
What Is ConMan?
ConMan is a rather simple contact manager that can be used to remember names, addresses,
and phone numbers of important clients, friends, and family. You can also enter notes for each
contact and store reminders.
Multiple users can run this application simultaneously, and provisions in the application allow
a user to store the database onto a local machine to use while away from the office (briefcase
model).
Although ConMan demonstrates many important multiuser database concepts, it isn’t going to
replace your production-quality contact manager (such as ACT! or GoldMine) anytime soon.
Many important features of a commercial contact manager are missing from ConMan, includ-
ing the capability to dial the phone, automatically write letters, faxes, and memos using your
favorite word processor, and so on. The intent of ConMan is not to write a salable product.
After all, if that were the case, I wouldn’t be including the source code for free in a database
book. Rather, the intent is to show a real-life application that makes use of important Delphi
database technologies to give you a concrete understanding of how you might take advantage
of the same technologies in your own applications.
Figure 9.1 shows ConMan at runtime.
Because ConMan takes advantage of technologies not yet supported by Kylix—such as
DataSnap—it is presented as a VCL application only.
The ConMan Application
349
FIGURE 9.1
ConMan displays information about a contact, as well as his or her picture, notes, and scheduled todos.
Database Structure
You have already accessed some of the tables in the ConMan database through the sample pro-
grams provided with earlier chapters. Listing 9.1 contains the partial SQL script used to create
a new, empty version of the CONMAN database.
The Contacts table is where company names, addresses, phone numbers, and the like are
stored. One record is created for each contact, so if multiple contacts exist for the same com- 9
pany, the company data is stored with each contact.
THE CONMAN
APPLICATION
NOTE
In reality, this database should also contain a Companies table, with each contact
holding a reference back to the company to which he or she is associated. To keep
the size of this application manageable, I elected to store both company and contact
data in the Contacts table.
Chapter 9
350
The Todos table stores reminders for each contact. For example, you might want to store a
reminder to phone a certain contact on a given date to place an order. The Todos table and
Contacts table are linked through the Todos table’s ContactID field.
In addition to the tables, the database contains a stored procedure named ContactsByState.
This procedure is provided for use in the sample applications shown in Chapters 1 and 2; it
won’t be used in the ConMan application.
Finally, the script creates a generator used to populate the primary keys of the Contacts and
Todos tables and two triggers that run when a new contact and todo are entered, respectively.
The triggers ensure that new contacts and todos always receive a unique primary key.
/* Generators */
CREATE GENERATOR ID_GENERATOR;
SET TERM ^ ;
/* Stored Procedures */
THE CONMAN
APPLICATION
END ^
SET TERM ; ^
Chapter 9
352
FIGURE 9.2
The remote data module and its data access components.
interface
uses
Windows, Messages, SysUtils, Classes, ComServ, ComObj, VCLCom, DataBkr, 9
DBClient, ConManServer_TLB, StdVcl, DBXpress, FMTBcd, DB, SqlExpr,
THE CONMAN
Provider;
APPLICATION
type
TConManDataServer = class(TRemoteDataModule, IConManDataServer)
conn: TSQLConnection;
sqlContacts: TSQLDataSet;
sqlTodos: TSQLDataSet;
dsContacts: TDataSource;
pvContacts: TDataSetProvider;
sqlID: TSQLDataSet;
sqlContactsCONTACTID: TIntegerField;
sqlContactsFIRST: TStringField;
sqlContactsLAST: TStringField;
sqlContactsDEAR: TStringField;
Chapter 9
354
implementation
uses MainForm;
resourcestring
SDatabaseIsOpen = ‘Cannot perform this operation on an open database’;
{$R *.DFM}
The ConMan Application
355
THE CONMAN
APPLICATION
if SourceDS = sqlContacts then begin
if DeltaDS.FieldByName(‘CONTACTID’).OldValue <= 0 then
DeltaDS.FieldByName(‘CONTACTID’).NewValue := GetNextID;
end else begin
if DeltaDS.FieldByName(‘TODOID’).OldValue <= 0 then
DeltaDS.FieldByName(‘TODOID’).NewValue := GetNextID;
end;
end;
initialization
TComponentFactory.Create(ComServer, TConManDataServer,
Class_ConManDataServer, ciMultiInstance, tmApartment);
end.
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, ExtCtrls, ComCtrls;
const
UM_CONNECT = WM_USER + 101;
type
TfrmMain = class(TForm)
StatusBar1: TStatusBar;
pnlConnections: TPanel;
Timer1: TTimer;
procedure Timer1Timer(Sender: TObject);
private
The ConMan Application
357
var
frmMain: TfrmMain;
implementation
resourcestring
SOneConnection = ‘1 Connection’;
SConnections = ‘%d Connections’;
{$R *.dfm}
{ TfrmMain }
THE CONMAN
APPLICATION
procedure TfrmMain.Timer1Timer(Sender: TObject);
var
HS: THeapStatus;
begin
HS := GetHeapStatus;
StatusBar1.SimpleText := Format(SHeapAllocated,
[FloatToStrF(HS.TotalAllocated, ffNumber, 10, 0)]);
end;
end.
Chapter 9
358
Listing 9.3 defines a user-defined message, UM_CONNECT. The remote data module posts this
message to the main form on creation and destruction. The UMConnect method fires in response
to the UM_CONNECT message and updates a label showing the number of current connections to
the application server.
The only other code in Listing 9.3 is the Timer1Timer method, which fires every second to dis-
play the amount of RAM currently being used by the application server.
Figure 9.3 shows the application server at runtime.
FIGURE 9.3
The application server with a minimal user interface.
interface
uses
SysUtils, Classes, SConnect, DB, DBClient, MConnect, Dialogs;
type
TDM = class(TDataModule)
SocketConnection1: TSocketConnection;
cdsContacts: TClientDataSet;
cdsTodos: TClientDataSet;
cdsContactsCONTACTID: TIntegerField;
cdsContactsFIRST: TStringField;
cdsContactsLAST: TStringField;
cdsContactsDEAR: TStringField;
cdsContactsTITLE: TStringField;
cdsContactsCOMPANYNAME: TStringField;
cdsContactsADDRESS1: TStringField;
The ConMan Application
359
THE CONMAN
APPLICATION
{ Public declarations }
function GetNextID(DataSet: TCustomClientDataSet;
const PrimaryKey: string): Integer;
end;
var
DM: TDM;
implementation
uses RecErrorForm;
Chapter 9
360
{$R *.dfm}
// Dataset events
// Connection events
end.
9
THE CONMAN
The first two methods of interest are the DataModuleCreate and DataModuleDestroy methods. APPLICATION
DataModuleCreate opens the cdsContacts table, which attempts to load local data from the
file CONMAN.CDS because the component’s FileName property is set to CONMAN.CDS. If
the file does not exist, the program will display a blank screen—it doesn’t attempt to connect
to the application server automatically.
DataModuleDestroy closes the cdsContacts table and also the connection to the application
server, if a connection was established. Because the cdsContacts.FileName property is set, the
program automatically writes the data to the file CONMAN.CDS. In addition, the todo dataset
is stored in the same CDS file because it is a nested dataset of cdsContacts.
Chapter 9
362
The cdsContactsNewRecord and cdsTodosNewRecord events make a call to the local method
GetNextID. GetNextID clones the dataset in question and moves to the first record in the
dataset. The first record is the record with the lowest numbered ID. It then assigns the next
lower number to the ID of that record, ensuring that the record has a negative primary key.
The reason this is done is to ensure that all records that have not yet been added to the data-
base have a negative ID. The server-side data module checks for a negative ID and then calls
the database’s GEN_ID stored procedure to assign a unique ID. This ensures that all added
records receive a unique ID, regardless of what computer they were added from. (Remember
that multiple users may be running this application at the same time.)
NOTE
An alternative to using the cloned dataset to retrieve the next negative ID is to set up
a private variable and retrieve the next ID at program startup. That way you can sim-
ply decrement the variable to obtain the next ID. The reason I don’t do this is because
in real-world applications I sometimes have 40 or 50 datasets in my data module. I
don’t like to set up 40 or 50 variables—one for each dataset. Instead, I use the com-
mon function to retrieve the next ID on the fly.
Note that cloning a dataset does temporarily require a small amount of memory, and
also takes a small amount of time to do. If you need to generate temporary IDs as
quickly as possible, you should consider using a private variable instead.
Listing 9.5 contains the source code for the client’s main form, which is where the bulk of the
code for this application lies.
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, DBActns, StdActns, ActnList, ImgList, ActnCtrls, ToolWin,
ActnMan, ActnMenus, ComCtrls, BandActn, ExtCtrls, Menus, Grids, DBGrids,
DB, StdCtrls, DBCtrls, Mask, ExtDlgs;
type
TfrmMain = class(TForm)
StatusBar1: TStatusBar;
ImageList1: TImageList;
The ConMan Application
363
THE CONMAN
APPLICATION
ToolButton2: TToolButton;
ToolButton3: TToolButton;
pnlClient: TPanel;
PageControl1: TPageControl;
tabGrid: TTabSheet;
tabForm: TTabSheet;
gridContacts: TDBGrid;
pnlTodos: TPanel;
dsContacts: TDataSource;
dsTodos: TDataSource;
PageControl2: TPageControl;
tabTodos: TTabSheet;
tabNotes: TTabSheet;
gridTodos: TDBGrid;
Chapter 9
364
THE CONMAN
APPLICATION
Label11: TLabel;
Label12: TLabel;
ecPhone: TDBEdit;
Label13: TLabel;
ecFax: TDBEdit;
ecCellular: TDBEdit;
Label14: TLabel;
Label15: TLabel;
ecPager: TDBEdit;
ecEmail: TDBEdit;
Label16: TLabel;
DeleteTodo1: TMenuItem;
procedure FileConnect1Update(Sender: TObject);
Chapter 9
366
var
frmMain: TfrmMain;
implementation
resourcestring
SConnect = ‘&Connect to Database Server’;
SDisconnect = ‘&Disconnect from Database Server’;
SOnline = ‘Online’;
SOffline = ‘Offline’;
THE CONMAN
APPLICATION
begin
if Panel.Index = 0 then begin
if DM.SocketConnection1.Connected then begin
StatusBar.Canvas.Font.Color := clGreen;
StatusBar.Canvas.TextRect(Rect, Rect.Left + 2, Rect.Top + 1, SOnline);
end else begin
StatusBar.Canvas.Font.Color := clRed;
StatusBar.Canvas.TextRect(Rect, Rect.Left + 2, Rect.Top + 1, SOffline);
end;
end;
end;
Chapter 9
368
// Image controls
// File menu
// Dataset menu
THE CONMAN
APPLICATION
else
StatusBar1.Panels[1].Text := Format(SUpdates,
[DM.cdsContacts.ChangeCount]);
end;
end.
The source code shown in Listing 9.5 deserves some discussion. I have grouped related sets of
event handlers in the source code.
The form’s OnCreate event handler sets the data module’s TSocketConnection.AfterConnect
and AfterDisconnect event handlers to point to the main form’s DoConnectDisconnect
method. This way, when a connection is established to or broken from the application server,
the main form will learn of the connect or disconnect. DoConnectDisconnect simply invali-
dates the status bar, which will then be updated to show the new connection status. 9
These event handlers are assigned at runtime rather than design time because the event handler
THE CONMAN
is not located in the same unit as the data module. Because the data module doesn’t use the APPLICATION
main form, the event handlers can’t be assigned at design time.
dsContactsDataChange is fired whenever the contact data source detects a change in the data.
The event handler checks to see if the modified field is nil (meaning the current record
changed) or if it references the IMAGE field (meaning the image for the current record
changed). If so, the code loads the current image from the dataset and displays it on the main
form.
ImageLoad1Execute and ImageClear1Execute fire when the user loads a new image for the
current contact or clears the contact’s image, respectively. Both event handlers put the contact
dataset into edit mode and then either clear the image or load a new image, respectively. As a
result of either of these events, dsContactsDataChange fires automatically, causing the dis-
played image to update.
Chapter 9
372
The only interesting action on the File menu is the Connect/Disconnect menu item.
FileConnect1Execute simply toggles the socket connection’s Connected property to connect
to or disconnect from the application server. FileConnect1Update takes care of setting the
action’s caption appropriately, depending on the current connection status.
Finally, the todo pop-up menu contains several actions for manipulating the todo items for the
current contact. TodoAddExecute and TodoEditExecute display the frmTodo form, which
allows the user to enter or modify a todo. TodoMarkDone1Execute sets the completed date and
time for the current todo to the current date and time.
The rest of the code in the main form should be fairly self-explanatory, so I won’t go over it in
detail.
The final unit in this application is TodoForm.pas. Listing 9.6 contains the source code for this
file, which enables the user to enter a new todo for a contact or edit an existing todo.
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, ExtCtrls, ComCtrls, StdCtrls;
type
TfrmTodo = class(TForm)
pnlClient: TPanel;
pnlBottom: TPanel;
Label1: TLabel;
ecDescription: TEdit;
Label2: TLabel;
dtDate: TDateTimePicker;
dtTime: TDateTimePicker;
Label3: TLabel;
btnCancel: TButton;
btnOk: TButton;
procedure FormCreate(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
The ConMan Application
373
{$R *.dfm}
end.
The todo form’s FormCreate handler simply defaults the date and time of the todo to noon of
the current date. The user will modify the date and time accordingly.
THE CONMAN
I’ll leave it to you to implement these suggestions if you see fit. That will give you a spring-
board to learning multitier development techniques on your own. APPLICATION
Summary
This chapter pulled together many of the concepts discussed in prior chapters to create a com-
plete multitier database application. Specifically, this application implements the following:
• The application server makes use of dbExpress components such as TSQLConnection and
TSQLDataSet for connecting to an Interbase database. These components are discussed in
Chapters 1 and 2.
Chapter 9
374
• The application server contains a minimal user interface that displays the number of cur-
rent connections along with the amount of RAM in use by the server, as shown in
Chapter 8.
• The server’s pvContactsBeforeUpdateRecord event handler shows how to ensure that
each record has a unique primary key just before posting to the database, as discussed in
Chapter 7.
• The client application makes use of a calculated field on the cdsContact dataset.
Calculated fields were discussed in Chapter 3.
• The client application’s dsContactsDataChange event handler takes care of displaying an
image from the dataset in a non data-aware TImage component. This concept was dis-
cussed in Chapter 4.
• The client application’s cdsContactsReconcileError event handler takes advantage of
Delphi’s built-in reconciliation form to handle reconciliation errors, as discussed in
Chapter 7.
Redistributing dbExpress APPENDIX
A
Applications
IN THIS APPENDIX
• Redistributable Files 376
This appendix tells you what you need to know to redistribute your database applications after
you’ve written them. Because this book assumes that you’re using dbExpress as the data access
technology behind your applications, I won’t explain how to redistribute the BDE or ADO.
Instead, I’ll concentrate specifically on dbExpress and DataSnap.
Redistributable Files
Redistributing an application built with dbExpress and DataSnap is incredibly simple—espe-
cially so if you’re used to messing around with a huge BDE- or ADO-based installation set. To
support dbExpress and DataSnap, you need to redistribute only two files along with your appli-
cation.
NOTE
Well, that’s two files in addition to whatever else your application might need. For
example, if you build your application with runtime packages enabled, you will also
need to redistribute the required runtime packages for the application.
In addition to the dbExpress driver, you need to redistribute MIDAS.DLL, which contains the
code necessary for the client dataset technology.
You can place both the dbExpress driver and MIDAS.DLL anywhere where your application
can find them, which could be either the application directory or the SYSTEM32 directory.
Neither file needs to be registered with Windows.
Redistributing dbExpress Applications
377
If you prefer, you can statically link DataSnap into your client application by including
MidasLib to your project’s uses clause and rebuilding. If you do this, you don’t need to redis-
tribute MIDAS.DLL with the client application (it must be redistributed with the server appli-
cation, however).
You can also statically link the dbExpress drivers with your application by linking in the
appropriate file(s), as listed in Table A.2.
NOTE
Even though Delphi 6 supports statically linking dbExpress and/or DataSnap to your
application, you should be aware that the static linking is a new technology that isn’t
completely compatible with all DataSnap code. For example, attempting to call
TClientDataSet.SaveToFile to save a client dataset to an XML file doesn’t save to
the same format when statically linking as when dynamically linking. The bottom line
is, if you decide to statically link your application, please test thoroughly before
releasing to your clients.
Oracle libsqlor.so
DB2 libsqldb2.so
MySQL libsqlmy.so
Appendix A
378
In addition to the dbExpress driver, you need to redistribute LIBMIDAS.SO. The redistrib-
utable files should be placed in /home/<usrname>/delphidir/bin.
Licensing Issues
If you’re writing database applications that work with local data (that is, data does not travel
from one machine to another), you don’t need to worry about any licensing fees for your appli-
cations. However, if data packets travel from one machine to another, you will need to pay a
small redistribution fee to Borland for use of the DataSnap technology.
For the particulars about when you need to pay the redistribution fees and how much they are,
visit Borland’s Web site at http://www.borland.com/midas.
CD-ROM-Based Applications
A common requirement of many database applications is that they need to be able to access a
(read-only) database on a CD. These applications, such as phone lists, parts databases, and so
on, are often redistributed with a large database on CD. The end user doesn’t need to update
the data—the user needs only to access the data from the CD.
To redistribute a read-only InterBase database on CD, you need to first run the gfix command
against the database, like this:
gfix –read-only mydata.gdb
B
IN THIS APPENDIX
• What Is dbExpress Plus? 380
This appendix takes a quick look at dbExpress Plus, Thomas Miller’s open source add-on
library for dbExpress. dbExpress Plus can be downloaded from CodeCentral at
codecentral.borland.com. It is ID #15945.
Scripting
TSQLScript provides a means of executing multiple SQL statements at once through what is
known as a script. You’re probably familiar with SQL script files. The following snippet shows
a simple script file. This is actually part of the SQL script used to create the CONMAN database.
CREATE TABLE CONTACTS
(
CONTACTID INTEGER NOT NULL,
FIRST VARCHAR(20),
LAST VARCHAR(30),
DEAR VARCHAR(40),
TITLE VARCHAR(30),
COMPANYNAME VARCHAR(50),
ADDRESS1 VARCHAR(50),
ADDRESS2 VARCHAR(50),
CITY VARCHAR(30),
STATE VARCHAR(20),
POSTALCODE VARCHAR(10),
COUNTRY VARCHAR(30),
PHONE VARCHAR(20),
FAX VARCHAR(20),
CELLULAR VARCHAR(20),
PAGER VARCHAR(20),
EMAIL VARCHAR(40),
IMAGE BLOB SUB_TYPE 0 SEGMENT SIZE 4096,
NOTES BLOB SUB_TYPE TEXT SEGMENT SIZE 4096,
PRIMARY KEY (CONTACTID)
);
dbExpress Plus
381
DBEXPRESS
CREATE TABLE TODOS
(
TODOID INTEGER NOT NULL,
CONTACTID INTEGER NOT NULL,
PLUS
DESCRIPTION VARCHAR(50),
SCHEDULED TIMESTAMP,
COMPLETED TIMESTAMP,
PRIMARY KEY (TODOID),
FOREIGN KEY (CONTACTID) REFERENCES CONTACTS (CONTACTID) ON DELETE CASCADE
);
Ordinarily, you’d need to execute these three statements (CREATE TABLE, CREATE INDEX, and
CREATE TABLE) separately by calling TSQLConnection.ExecuteDirect three times, once for
each statement.
Using TSQLScript, you can use the following two lines of code:
SQLScript1.SQL.LoadFromFile(ScriptFile);
SQLScript1.ExecuteDirect;
This code snippet assumes that ScriptFile is a string variable that contains the filename of
the SQL script file.
Enhanced Metadata
TSQLMetaData provides additional, easy-to-use methods to retrieve metadata information from
a database. Because it derives from TSQLConnection, you can use TSQLMetaData in place of a
TSQLConnection in your projects in which you need enhanced metadata.
TSQLMetaData.GetFieldNames retrieves the fields that make up a table. Pass in the table name
to retrieve the field names for a string list for the results, and a flag that indicates whether
fields should be returned in the order in which they are declared or in alphabetical order.
TSQLMetaData.GetFieldNames is defined like this:
By default, fields are returned in the order in which they were declared. By passing soName as
the final parameter to GetFieldNames, the field names are returned in alphabetical order.
This method takes the table name and field name as parameters and returns a TFieldMetaData
record containing information about the field in question. TFieldMetaData is defined like this:
TFieldMetaData = record
ColumnName: string;
ColumnPosition: LongInt;
ColumnDataType: LongInt;
ColumnTypeName: string;
ColumnSubtype: LongInt;
ColumnLength: LongInt;
ColumnPrecision: LongInt;
ColumnScale: LongInt;
ColumnNullable: LongInt; // 1=Not Nullable, 0=Nullable
end;
As you can see, it specifies the column (or field) name, the zero-based index, the type of data
contained in the column, and other pertinent information about the field.
GetIndexFieldNames returns a string list composed of the fields that make up the index
AIndexName on table ATableName.
know the name of the primary key. In that case, you can call either GetPrimaryKeyFieldNames B
or GetPrimaryKeyFields.
DBEXPRESS
procedure GetPrimaryKeyFieldNames(const ATableName: string;
AList: TStrings);
function GetPrimaryKeyFields(const ATableName: string): string;
PLUS
These two methods perform the same service as GetIndexFieldNames and GetIndexFields,
except that they always work on the primary key.
Additional Methods
TSQLMetaData also provides a collection of methods to return the INSERT, UPDATE, and SELECT
SQL statements for a given table. The three methods are listed next:
function GetInsertStatement(const ATableName: string;
ASQL: TStrings): Integer;
function GetUpdateStatement(const ATableName: string;
ASQL: TStrings): Integer;
function GetSelectStatement(const ATableName: string;
ASQL: TStrings): Integer;
Given a table name, these three methods return the corresponding INSERT, UPDATE, or SELECT
SQL statement in the ASQL parameter.
Data Pumping
TSQLDataPump provides an answer to the BDE’s TBatchMove component, allowing you to eas-
ily move data from one table to another.
To do this, TSQLDataPump publishes SQLMetaDataSource and SQLMetaDataDestination prop-
erties, which reference the TSQLMetaData components that point to the source and destination
database connections, respectively.
After you specify the source and destination databases, set the SQLSource property to a valid
SELECT statement for the source database, such as
Next, set the DestinationTable property to the name of the table in the destination database,
and then double-click the DestinationFields property, which allows you to set up a mapping
between the fields in the source table and the fields in the destination table.
Finally, set the DataMoveMode to the appropriate action to perform. Table B.1 lists the modes
that may be used.
Appendix B
384
At this point, all the required properties are set, so you need to call only
TSQLDataPump.Execute to perform the batch move operation.
N nonpersistent OnColumnMoved
aggregates, creating event, 246
Name property, 120 at design time, OnDataChange event,
named connections, 194-195 205, 226
9-11 nonpersistent fields, OnDeleteError event,
navigating 104-105 150
client datasets, 113 Not operator, 128 OnDrawColumnCell
random-access notes (BLOBs), storing, event, 246, 252
navigation, 114-116 162 OnDrawDataCell event,
sequential numeric fields, 246, 252
navigation, 113 formatting/editing, one-to-many
datasets, 65, 68 207-209 relationships, 74
Nested application OnEditButtonClick
(MainForm.pas), code event, 246
listing, 174-175
O OnEditError event, 150
nested datasets, OnEditingChange
172-176 Object-Insight Web
event, 226
nested transactions, 40 site, 272
OnFilterRecord event,
Neutral value, 321 ObjectBroker property,
130, 150
New command (File 344
OnGetData event,
menu), 319 ObjectName property,
296-298, 341
New Field dialog box, 335
OnGetDataSetPropertie
97 objects
s event, 297
New Interface button, field, retrieving, 257
OnGetTableName
325 Fields, accessing, 64
event, 297
New Items dialog box, TColumn, creating, 242
OnLogin event, 12, 14
280, 319 TFieldDef, 102
OnLogTrace event, 50
New menu commands ObjectView
OnNewRecord event,
(Other), 280, 319 property, 61
150
New Method toolbar OnActiveChange
OnPaintPanel event,
button, 325 event, 226
267-268
Next button, 224 OnCalcFields event,
OnPostError event, 150
Next method, 113 150
OnReconcileError
nonindexed search OnCalcFields
event, 278
methods, 136 method, 99
OnStateChange event,
Locate method, 136-137 OnCellClick event, 245
205
Lookup method, OnColEnter event, 245
OnTitleClick event, 246
137-138 OnColExit event, 246
OnTrace event, 50
OnUpdateData event
404