Borland ® Data Provider (BDP) for the Microsoft® .NET Framework
A Borland White Paper by Ramesh Theivendran, January 2004
Contents
When Microsoft released the .NET Framework, it introduced many new
technologies, including a new data-access model, ADO.NET. ADO.NET is a
disconnected, n-tier data-access model with better integration with XML and
XSD. ADO.NET has two core components: .NET Data Provider and DataSet. The
Data Provider defines a set of interfaces for data access and provides and
resolves data to and from a DataSet. DataSet represents relational data in
memory from a data source. The data source can be either a Relational
Database Management System (RDBMS) or an XML file.
This paper introduces the Borland
® Data Provider (BDP) for the Microsoft® .NET Framework included in Borland rapid application development (RAD) tools,
such as the newly released Borland ® C#Buildertm - a pure C# development solution for .NET. BDP is a .NET data provider
implementing the Microsoft .NET Data Provider core interfaces. BDP provides a
set of Common Language Runtime (CLR) components that allows data access from
one of the BDP supported databases. BDP also comes with a rich set of
component designers and tools that make database development easier.
BDP is an open architecture that exposes a set of interfaces for third-party
integration. One can implement these interfaces for their own database and
provide design-time, tools, and runtime data-access integration into the
Borland ® C#Builder IDE. BDP-managed components talk to these interfaces for all basic
data-access functionalities. The implementation of these interfaces wraps
database-specific native client libraries through platform Invoke (PInvoke)
services. Depending on the availability of managed database clients, one can
have a fully managed provider implemented underneath BDP.

Figure 1: BDP components, designers, and tools talking to core BDP interfaces
The database-specific implementation is wrapped into an assembly, and the
full name of the assembly is passed to the BdpConnection component as part of the connection string. Depending on the Assembly entry
specified in the BdpConnection.ConnectionString property, BDP dynamically
loads the database-specific provider and consumes the implementation for ISQLConnection, ISQLCommand, and ISQLCursor . This allows applications to be switched from one database to another just
by changing the ConnectionString property to a different provider
implementation.
BDP maps SQL datatypes to .NET Framework datatypes, avoiding the need to
learn a database-specific type system and making it easier for .NET
developers to do database development. Also, whenever possible, an attempt is
made to have consistent datatype mapping across databases. So, with BDP, one
can write a single set of source that can be used to talk with multiple
databases. Today, one can achieve the same with the .NET Framework data
providers by talking to their interfaces directly and using untyped
accessors. However, an application becomes less portable once strongly typed
accessors are used. BDP by default does not support any database-specific
typed accessors.
BDP components expose optional property strings that can be used to surface
database- specific features.
BDP is currently available only on Microsoft
® Windows,® but, depending on the availability of the C# compiler and CLR on other
platforms, BDP components can be ported there as well.
The .NET Framework 1.1 ships with five different data providers:
System.Data.SQLClient, System.Data.SQLServerCe, System.Data.Oledb,
System.Data.Odbc, and System.Data.OracleClient. The BDP components, metadata
access, and designers are defined under the following namespaces:
Borland.Data.Provider, Borland.Data.Common, Borland.Data.Schema, and
Borland.Data.Design. BDP currently supports Borland ® InterBase,® Oracle,® IBM® DB2,® and Microsoft® SQL Serverd 2000.
Figure 2: ADO.NET data provider architecture
Namespace: Borland.Data.Provider.BdpConnection
public sealed class BdpConnection : Component, IDbConnection, ICloneable
To establish a database connection, a new
BdpConnection must be created and its ConnectionString property set. ConnectionString is a name-value pair of all the parameters needed to connect to a particular
database. The ConnectionString is used as an identifier for creating a connection pool. The current
implementation of BDP does not support connection pooling.
ConnectionOption , another property, holds a name-value pair of database-specific connection-
level properties.
BdpConnection has Open() and Close() methods to connect and disconnect from a database. Open() uses the connection parameters specified in the ConnectionString to establish
a connection to a database. For every Open(), there should be a corresponding Close() connection. Otherwise, subsequent connection attempts will fail with an
exception.
A valid connection must be opened before the
BdpConnection can be used to create a new BdpCommand by calling CreateCommand(), start a new transaction by calling BeginTransaction(), change database by calling ChangeDatabase(), get access to the ISQLMetadata or ISQLResolver by calling GetMetaData() or GetResolver(), respectively.
ISQLMetadata and ISQLResolver are BDP extensions to ADO.NET data providers to
simplify schema retrieval and resolver SQL generation.
The following is a code snippet that shows ConnectionString and
ConnectionOptions to connect to InterBase.
using System;
using System.Data;
using Borland.Data.Provider;
using Borland.Data.Common;
class test
{
static void Main()
{
ConnectionState state;
BdpTransaction Trans = null;
BdpCommand Comm = null;
String ConnStr = "Provider=interbase;"
+ "Assembly=Borland.Data.Interbase,Version=1.1.0.0,"
+ "Culture=neutral,PublicKeyToken=91d62ebb5b0d1b1b;"
+ "Database=localhost:c:\\IB7\\examples\\database\\employee.gdb;"
+ "UserName=sysdba;Password=masterkey";
String ConnOptions = "LoginPrompt=true;SQLDialect=3;RoleName=Vina;"
+ "CommitRetain=true;ServerCharSet=UNICODE_FSS;RollbackRetain=False;"
+ "TxnIsolation=ReadCommitted;WaitOnLocks=true";
BdpConnection Conn = new BdpConnection();
Conn.ConnectionString = ConnStr;
Conn.ConnectionOptions = ConnOptions;
try
{
Conn.Open();
}
catch ( BdpException e)
{
for (int j= 0; j < e.Errors.Count; j++)
{
Console.WriteLine(e.Errors[j].Message);
}
return;
}
Trans = (BdpTransaction)Conn.BeginTransaction();
Comm = (BdpCommand) Conn.CreateCommand();
Comm.Connection = Conn;
Comm.Transaction = Trans;
----
Comm.Close();
Trans.Commit();
Conn.Close();
}
}
Namespace: Borland.Data.Provider.BdpCommand
public sealed class BdpCommand : Component, IDbCommand, ICloneable
BdpCommand provides a set of methods and properties for SQL and stored procedure
execution. The CommandType property specifies whether a SQL statement or a table name or a stored
procedure name is being used in the CommandText property. Before a SQL statement can be executed, the Connection property should be set to a valid BdpConnection. To execute a SQL statement in the context of a transaction, the Transaction property must be set to a valid BdpTransaction.
Depending on the type of SQL statement being executed, one of the following
methods can be used: ExecuteScalar, ExecuteReader, or ExecuteNonQuery. ExecuteNonQuery(), as the name implies, is for executing DDL or non-SELECT DML statements or stored procedures that return no cursor. On successful
execution of a DML, ExecuteNonQuery() returns the number of rows affected on the database. ExecuteReader() is used for SELECT statements and stored procedures that return a cursor or multiple cursors. A
new BdpDataReader is returned if ExecuteReader() successfully processes the SQL statement. ExecuteScalar() is similar to ExecuteReader() , but it returns only the first column of the first record as an object and
is mainly used for executing SQL to get aggregate values.
For parameterized SQL execution,
CommandText can be specified with parameter markers. BDP uses "?" for parameter markers.
In the current implementation, there is no support for named parameters. The ParameterCount property specifies the number of parameter markers in the CommandText . During preparation, the database-specific BDP provider implementation takes
care of converting the "?" parameter markers to database- specific parameter
markers and also takes care of generating the appropriate SQL for calling a
stored procedure.
When executing the same SQL repeatedly, it is optimal to call
Prepare() once and bind parameters. Parameters are specified by adding a BdpParameterCollection to the BdpCommand.Parameters property. Preparing a parameterized SQL
statement on most databases creates an execution plan on the server that will
be used during subsequent execution of the same SQL with different parameter
values.
Once a
BdpCommand is done, calling Close() frees all the statement resources allocated by the provider.
BdpCommand.CommandOptions is for passing optional command-level properties.
Namespace: Borland.Data.Provider.BdpDataReader
public sealed class BdpDataReader: MarshalByRefObject, IEnumerable,
IDataReader, IDisposable, IDataRecord
A
BdpDataReader is returned as a result of a SELECT or stored procedure execution from BdpCommand.ExecuteReader(). Because there
is no public constructor, an instance of a BdpDataReader cannot be instantiated. BdpDataReader provides a forward-only cursor and the associated cursor-level metadata.
BdpDataReader methods such as GetName(), GetDataTypeName(), GetFieldType(), GetDataType(), and GetDataSubType() provide the cursor-level metadata. For all of these methods, the ordinal of
the column in the cursor, which is zero based, must be passed. Given a column
name, GetOrdinal() returns the column ordinal or position in the select list. GetName(), GetDataTypeName(), and GetFieldType() return the name, SQL datatype name, and the .NET Framework System.Type,
respectively, for a particular column. GetDataType() and GetDataSubType() return the BDP logical datatype and the
subtype, respectively.
GetSchemaTable() also can be used to retrieve the metadata of the cursor as a DataTable.
BdpDataReader.Read() can be called to fetch records one after the other until
a false is returned, which indicates that one is at EOF. Before accessing
individual column values, one can check if the data is NULL by calling IsDBNull(). Then, depending on the datatype, one of the field accessor methods, such as GetInt16(), GetInt32(), and GetFloat() , to name a few, can be called. BLOB data can be accessed as a byte array or
a character array by calling GetBytes() or GetChars() . A null buffer passed to these methods returns the size of the BLOB data
available. The current implementation of BdpDataReader does not support
fetching BLOB data by specifying offsets.
The initial state of the
BdpDataReader returned from BdpCommand.ExecuteReader() is open. Once all of the records
have been fetched, the Close() can be called to free all of the cursor-related resources. To find out if a BdpDataReader is closed, the IsClosed property can be checked. If the BdpDataReader is closed, it will return true otherwise false. If
CommandBehaviour.CloseConnection is specified in ExecuteReader(), the BdpConnection used for executing the SQL is also closed while the BdpDataReader is closed.
NextResult() returns true if more results are available from a stored procedure execution.
The following is a code snippet that shows retrieving data using a
BdpDataReader, assuming that a valid connection and command are ready.
public static void ExecuteCommand ( BdpCommand Comm )
{
Comm.CommandText = " SELECT * FROM EMPLOYEE";
Comm.Prepare();
BdpDataReader Reader = Comm.ExecuteReader();
if ( Reader != null )
{
while (Reader.Read())
{
ShowData(Reader);
}
Reader.Close();
}
Comm.Close();
}
public static void ShowData(BdpDataReader Reader)
{
long retVal = 0, startIndex = 0;
int buffSize = 0;
char []buffer = null;
for (Int32 index = 0; index < Reader.FieldCount; index++)
{
//Check for NULL
if ( Reader.IsDBNull(index) )
{
Console.Write("NULL");
}
else
{
Type t = Reader.GetFieldType(index);
if (t == typeof(Int16) )
Console.Write(Reader.GetInt16(index));
else if ( t == typeof(Int32) )
Console.Write(Reader.GetInt32(index));
else if ( t == typeof(String) )
Console.Write(Reader.GetString(index));
else if ( t == typeof(float) )
Console.Write(Reader.GetFloat(index));
else if ( t == typeof(double) )
Console.Write(Reader.GetDouble(index));
else if ( t == typeof(DateTime) )
Console.Write(Reader.GetDateTime(index));
else if ( t == typeof(Decimal) )
Console.Write(Reader.GetDecimal(index));
else if ( (t == typeof(Byte[])) || (t == typeof(Char[])) )
{
BdpType DataType = Reader.GetDataType(index);
if (DataType == BdpType.Blob)
{
retVal = Reader.GetChars(index, 0, null, 0, 0);
Console.Write("Blob Size = " + retVal);
buffSize = (int) retVal;
//Display only character blob data
if ( retVal > 0 && (t== typeof(Char[])) )
{
buffer = new char[buffSize];
startIndex = 0;
retVal = Reader.GetChars(index, startIndex, buffer, 0, buffSize);
for (int i = 0; i < buffer.Length; i++ )
Console.Write(buffer[i]);
}
}
}
}
if (index < Reader.FieldCount -1)
Console.Write(", ");
}
Console.WriteLine();
Namespace: Borland.Data.Common.BdpParameter,
Borland.Data.Common.BdpParameterCollection
public sealed class BdpParameter: MarshalByRefObject, IDbDataParameter,
IDataParameter, ICloneable
public sealed class BdpParameterCollection: MarshalByRefObject,
IDataParameterCollection, IList, ICollection, IEnumerable
To pass runtime parameters for a parameterized SQL statement or stored
procedure, the BdpParameterCollection class can be used. An empty BdpParameterCollection is returned by the BdpCommand.Parameters property. After successfully preparing the command, parameters are added to
the BdpParameterCollection by calling Add() and passing the parameter information such as name, datatype, precision,
scale, size, etc. One of the overloaded Add() methods available can be used, or individual BdpParameter properties such as Direction, Precision, Scale, DbType, BdpType, BdpSubType, Size, and MaxPrecison can be set. Parameter names must be unique, and the parameters must be added
to the BdpParameter collection in the same order in which parameters markers appear in the SQL.
This limitation will be removed once there is support for named parameters.
BdpParameter.Direction property by default is ParameterDirection.Input. In
the case of stored procedures, it can be set to Output, InputOutput, or ReturnValue . If the inout parameter is expected to return more data than the input,
BdpParameter.Precision should be specified to a size large enough to hold the
output data. Precision is a byte, and if values larger than 255 bytes must be
returned, the MaxPrecision property must be set. Note that not all databases support all the different
parameter directions.
While specifying the parameter datatype, either System.Type or the BDP
logical type and the subtype can be used. BdpParameter.Value should be set
with the runtime value for all parameters before executing the command. After
successful execution, output data is available in the Value property.
Namespace: Borland.Data.Provider.BdpTransaction
public sealed class BdpTransaction : MarshalByRefObject, IDbTransaction,
IDisposable
BdpTransaction takes care of controlling a database transaction with respect to a
connection. BdpConnection.BeginTransaction() on an opened connection returns
a new BdpTransaction object. Commit() and Rollback() take care of committing or rolling back a transaction. For setting different
isolation levels, the IsolationLevel property can be used, and the default isolation is ReadCommitted . The current implementation does not support multiple transactions on a
single connection.
Following is a code snippet that shows runtime parameter binding and
executing a stored procedure in the context of a transaction
static void BindParameter ( BdpConnection Conn, Int32 Times )
{
Int32 Count = 0, Rows = 0;
BdpTransaction Trans = (BdpTransaction) Conn.BeginTransaction();
BdpCommand Comm = (BdpCommand) Conn.CreateCommand();
Comm.Connection = Conn;
Comm.Transaction = Trans;
Comm.CommandType = CommandType.StoredProcedure;
Comm.CommandText = "MYTESTPROC";
Comm.ParameterCount = 3;
Comm.Prepare();
BdpParameter param1 = Comm.Parameters.Add("P1",
DbType.StringFixedLength, 10);
BdpParameter param2 = Comm.Parameters.Add("P2",
DbType.String, 5);
param2.Direction = ParameterDirection.InputOutput;
param2.Precision = 25;
BdpParameter param3 = Comm.Parameters.Add("P3",
DbType.Decimal);
param3.Direction = ParameterDirection.Output;
while ( Count < Times )
{
param1.Value = "Record" + Count;
param2.Value = "Hello";
param3.Value = null;
Rows = Comm.ExecuteNonQuery();
Console.WriteLine("Output param from MYTESTPROC= " + param2.Value );
Console.WriteLine("InputOutput param from MYTESTPROC= " + param3.Value );
Count ++;
}
Comm.Close();
Trans.Commit();
}
Namespace: Borland.Data.Provider.BdpDataAdapter
public sealed class BdpDataAdapter : DbDataAdapter, IDbDataAdapter,
IsupportInitialize
BdpDataAdapter acts as a conduit between the data source and the .NET DataSet. It provides
data from a data source to the DataSet and resolves DataSet changes back to
the data source. The BdpDataAdapter has a DataSet property, and this DataSet gets automatically filled with data
when the Active property on the BdpDataAdapter is set to True.
The
BdpDataAdapter uses the SelectCommand to provide data to a DataSet when the Fill() method is called. The BdpConnection associated with the SelectCommand is used to execute the command specified in the SelectCommand.CommandText. If
the BdpConnection is already opened, it is used; otherwise, Fill takes care of opening the connection, executing the SQL statement, and
retrieving the result set, and then closes the connection. While working with
large result sets, the number of records that will be populated into the
DataSet can be controlled by using the MaxRecords and the StartRecord properties.
The
BdpDataAdapter has InsertCommand, UpdateCommand, and DeleteCommand properties for resolving DataSet changes back into the data source. A valid
parameterized SQL statement for each of these commands must be specified
before the Update() method can be called. Based on the DataSet changes, the Update() method executes INSERT, UPDATE, and DELETE commands and persists all the changes to the data source.
BdpDataAdapter.AutoUpdate() method lets data be resolved automatically
without the need to specify all the SQL. AutoUpdate() in turn uses a BdpCommandBuilder to generate updates, delete, and insert SQL. Although using AutoUpdate() makes things look simpler, the current implementation does not generate
optimal SQL every time. Also, it does not handle master-detail updates. Until
these issues are addressed, be aware that simplicity is achieved at the cost
of performance.
For resolving data from a stored procedure or a complex SQL such as joins,
AutoUpdate() cannot be used. In these cases, the BdpDataAdapter DeleteCommand, UpdateCommand, and InsertCommand should be explicitly specified and the Update() method should be used.
Like
GetSchemaTable() method in BdpDataReader, the BdpDataAdapter also has a FillSchema() method that creates a DataTable and configures the metadata to match with the
database. Consequently, to have integrity constraints such as primary key and
unique key enforced in the DataSet, FillSchema() must be called before Fill() is called. BdpDataAdapter.TableMappings allows table, column names to be
mapped from the data source to more meaningful names.
The following is a code snippet that shows providing and resolving data with a
BdpDataAdapter.
static void FillDataAdapter ( BdpConnection Conn )
{
int Rows = 0;
BdpTransaction Trans = (BdpTransaction) Conn.BeginTransaction();
BdpDataAdapter adapter = new BdpDataAdapter();
BdpCommand Comm = new BdpCommand("SELECT * FROM TESTTABLE", Conn);
adapter.SelectCommand = Comm;
DataTableMapping dataTabMap = adapter.TableMappings.Add("Table1","TESTTABLE");
DataSet ds = new DataSet();
adapter.FillSchema(ds, SchemaType.Source, "TESTTABLE");
Rows = adapter.Fill(ds, "TESTTABLE");
InsertRecord(Conn, adapter, ds.Tables["TESTTABLE"]);
adapter.Update(ds,"TESTTABLE");
ds.AcceptChanges();
Trans.Commit();
}
static void InsertRecord(BdpConnection Conn, BdpDataAdapter adapter, DataTable dataTable )
{
BdpCommand CommIns = new BdpCommand("INSERT INTO TESTTABLE VALUES(?)", Conn);
BdpParameter param1 = CommIns.Parameters.Add("FCHAR",DbType.StringFixedLength, 10);
param1.SourceColumn = "FCHAR";
adapter.InsertCommand = CommIns;
//Insert 10 records
for ( int i=0; i < 10; i++)
{
DataRow newRow = dataTable.NewRow();
newRow["FCHAR"] = "VINA" + i;
dataTable.Rows.Add(newRow);
}
}
static void FillDataAdapter ( BdpConnection Conn )
{
int Rows = 0;
BdpTransaction Trans = (BdpTransaction) Conn.BeginTransaction();
BdpDataAdapter adapter = new BdpDataAdapter();
BdpCommand Comm = new BdpCommand("SELECT * FROM TESTTABLE", Conn);
adapter.SelectCommand = Comm;
DataTableMapping dataTabMap = adapter.TableMappings.Add("Table1","TESTTABLE");
DataSet ds = new DataSet();
adapter.FillSchema(ds, SchemaType.Source, "TESTTABLE");
Rows = adapter.Fill(ds, "TESTTABLE");
InsertRecord(Conn, adapter, ds.Tables["TESTTABLE"]);
adapter.Update(ds,"TESTTABLE");
ds.AcceptChanges();
Trans.Commit();
}
static void InsertRecord(BdpConnection Conn, BdpDataAdapter adapter, DataTable dataTable )
{
BdpCommand CommIns = new BdpCommand("INSERT INTO TESTTABLE VALUES(?)", Conn);
BdpParameter param1 = CommIns.Parameters.Add("FCHAR",DbType.StringFixedLength, 10);
param1.SourceColumn = "FCHAR";
adapter.InsertCommand = CommIns;
//Insert 10 records
for ( int i=0; i < 10; i++)
{
DataRow newRow = dataTable.NewRow();
newRow["FCHAR"] = "VINA" + i;
dataTable.Rows.Add(newRow);
}
}
BDP components come with a rich set of designers like the Connections Editor,
the Command Text Editor, and the Data Adapter Configuration dialog. These
designers can be accessed by right-clicking on the components or clicking on
the designer verbs associated with the components.
The Connections Editor manages connection strings and database-specific
connection properties. With the Connections Editor, connections can be added,
removed, deleted, renamed, and tested. Changes to the connection information
are then persisted into BdpConnections.xml. Once a particular connection is
chosen, the designer generates the connection string and the connection
options, and assigns them to BdpConnection.ConnectionString and
BdpConnection.ConnectionOptions properties, respectively.

Figure 3: BDP Connections Editor showing connection information in a Property Grid
The Command Text Editor is a simplified version of a SQL builder capable of
generating SQL for a single table. Depending on the SchemaName property in the BdpCommand , the database objects are filtered, and only tables on that schema are
listed. If a SchemaName is not specified, all of the available objects for the current login user are
listed. The value of QuoteObjects in the ConnectionOptions determines whether the objects will be quoted with the database-specific
quote character or not. The current implementation does not list available
stored procedures.
The Command Text Editor allows a table name from a list of available tables
and columns from a list of columns for a particular table to be chosen. Using
this information, it generates a SQL statement. To generate the SQL, the
designer uses a BdpCommandBuilder . When optimized SQL is requested, index information is used to generate the
"where" clause for SELECT, UPDATE, and DELETE statements; otherwise, non-BLOB columns and searchable columns form the
"where" clause.
Finally, BdpCommand.CommandText is set to the SQL statement that is generated.

Figure 4: BDP Command Text Editor
The Data Adapter Configuration is similar to the Command Text Editor, but it
allows the generation of SELECT, INSERT, UPDATE, and DELETE statements. After successful SQL generation, new BdpCommand objects are created and added to the BdpDataAdapter SelectCommand, DeleteCommand, InsertCommand, and UpdateCommand properties.

Figure 5: BDP Data Adapter Configuration dialog
After successful
SELECT SQL generation, data can be previewed and a new DataSet generated, or an
existing DataSet can be used to populate a new DataTable. If a new DataSet is
created, it is automatically added to the designer host. Once a BdpDataAdapter is configured, a DataGrid can be hooked up and its DataSource and DataMember properties set to see design-time data. BdpDataAdapter also has a designer verb for typed DataSet generation.

Figure 6: Preview Data for the SQL statement generated by the BDP resolver
Data Explorer uses the
ISQLDataSources interface to get a list of available providers, database connections, and
schema objects that are supported by different providers. The list of
available providers is persisted in BdpDataSources.xml, and the available
connections in BdpConnections.xml. Once a provider is chosen, the ISQLMetadata interface is used to retrieve metadata and display a read-only tree view of database objects. The current implementation provides a list of Tables,
Views, and Stored Procedures for all BDP-supported databases.
Data Explorer is integrated into the C#Builder IDE and also is available as a
standalone executable.
Figure 7: Data Explorer for browsing database objects
BDP provides a generic set of classes for data-access in .NET. It also makes
third-party integration into the C#Builder IDE easier and provides features
for schema retrieval and resolver SQL generation. As database vendors come
with fully managed clients for .NET, the BDP providers can also evolve to be
fully managed. Future versions of BDP are scheduled to have designer, tools,
and runtime enhancements to make .NET database application development even
easier.
Made in Borland ® Copyright © 2004 Borland Software Corporation. All rights reserved. All
Borland brand and product names are trademarks or registered trademarks of
Borland Software Corporation in the United States and other countries.
Microsoft, Windows, and other Microsoft product names are trademarks or
registered trademarks of Microsoft Corporation in the U.S. and other
countries. All other marks are the property of their respective owners.
Corporate Headquarters: 100 Enterprise Way, Scotts Valley, CA 95066-3249
831-431-1000 www.borland.com Offices in: Australia, Brazil, Canada,
China, Czech Republic, Finland, France, Germany, Hong Kong, Hungary, India,
Ireland, Italy, Japan, Korea, Mexico, the Netherlands, New Zealand, Russia,
Singapore, Spain, Sweden, Taiwan, the United Kingdom, and the United States.
21137
Note! You can also download this document as a PDF from CodeCentral.
Connect with Us