Borland Data Provider (BDP) for the Microsoft.NET Framework

By: John Kaster

Abstract: High-performance enterprise database development for ADO.NET

Borland ® Data Provider (BDP) for the Microsoft® .NET Framework

A Borland White Paper by Ramesh Theivendran, January 2004

Contents

Introduction

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 architecture

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.

BDP components

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

BdpConnection: Connecting to the database

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();
   }
}

BdpCommand: Executing SQL or stored procedure

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.

BdpDataReader: Retrieving data

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();            

BdpParameter: Runtime parameter binding

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.

BdpTransaction: Transaction control

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();
  }

BdpDataAdapter: Providing and resolving data

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 component designers

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.

Connections Editor

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

Command Text Editor

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

Data Adapter Configuration

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

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

Conclusion

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.

Note! You can also download this document as a PDF from CodeCentral.



Server Response from: ETNASC04