Introduction
We can use Attributes from .NET classes to generate a database schema - a database design including tables, columns, and data types - so that .NET objects can create their own database tables to be persisted into. Then we can extract this schema information to generate the SQL script to create the tables in a database, and to construct in-memory DataTable objects.
If a database structure changes over time then we need to ensure that updates to tables, such as adding new columns, are reflected in the classes that access the database.
Here, we will create subclasses of DataRow that are designed specifically for storing data from a particular database table. In cases where the underlying database schema will not change often, this can provide a very effective way to access databases. If your schema is likely to change frequently, or if you permit users to modify the database structure, it might be better to generate the DataTable objects dynamically by requesting schema information from the database and building the data tables on the fly.
We will demonstrate this by creating two tables, Author and Book, and will work with SQL Server. You could alter the code to work with Oracle or any other database engine.
The DataTable consists largely of boilerplate code, so we will define a base class DataTable object that can serve as a generic container for DataRow objects. The DataRow, however, needs to provide type-safe access to columns within the database, so we will subclass it.
The AuthorRow class will derive from DataRow, and includes properties for each of the columns within the underlying Author table. A DatabaseTable attribute will be added to the row class, and for each property that links to a column in the table, there will be a DatabaseColumn attribute.
DatabaseTable Attribute
The first attribute is used to mark a class, DataRow, with the name of the database table where the DataRow will be saved. Here is the code:
using System;
using System.Reflection;
[AttributeUsage(AttributeTargets.Class,Inherited=false,AllowMultiple=false )]
public class DatabaseTableAttribute : Attribute
{
public DatabaseTableAttribute ( string tableName )
{
TableName = tableName ;
}
public readonly string TableName ;
}
The constructor accepts the name of the table as a string, and is marked with the Inherited=false and AllowMultiple=false modifiers. There is no method to alter the table name here, so a readonly field will suffice.
DatabaseColumn Attribute
This attribute is designed to be placed on public properties of the DataRow class, and is used to define the name of the column that the property will link to, together with such things as whether the column can contain a null value:
[AttributeUsage(AttributeTargets.Property,Inherited=true,AllowMultiple=false)]
public class DatabaseColumnAttribute : Attribute
{
public DatabaseColumnAttribute ( string column , ColumnDomain dataType )
{
ColumnName = column ;
DataType = dataType ;
Order = GenerateOrderNumber ( ) ;
}
public readonly string ColumnName ;
public readonly ColumnDomain DataType ;
public bool Nullable = false ;
public int Order ;
public int Size ;
public static int GenerateOrderNumber ( )
{
return nextOrder++ ;
}
private static int nextOrder = 100 ;
}
public enum ColumnDomain
{
Integer,
Long,
String,
DateTime
}
We have marked this attribute as inheritable so that we can create a class hierarchy for database rows, as it is likely that we will have some similar columns throughout each table within the schema.
The Attribute also has three other properties:
Nullable, defaulting to false;
Order, the order number of the column within the table;
Size, the maximum number of characters allowed in a string type.
To define a Name column, we might use the following code:
[DatabaseColumn("NAME", ColumnDomain.String, Order=10, Size=64)]
public string Name
{
get { return (string) this["NAME"]; }
set { this["NAME"] = value; }
}
This defines a field called NAME, and will be generated as a VARCHAR(64) and will not allow null values as it is the default for the Nullable property. The DataRow class has an indexer that takes the name of a field (or ordinal) as the parameter. This returns an object, which is cast to a string before returning it in the get accessor above.
Creating Database Rows
Now, we'll create the two classes, Author and Book, which both derive from a common base class as they share some common fields. We'll also create a generic abstract class, GenericRow, which defines Name and Description properties. It is derived from DataRow, the base class for all database rows in the framework. Then, two classes will derive from GenericRow - one to represent an Author (AuthorRow) and another representing a Book (BookRow). These both contain additional properties, which are linked to fields within the database. Here is the code for it:
public abstract class GenericRow : DataRow
{
public GenericRow ( System.Data.DataRowBuilder builder )
: base ( builder )
{
}
[DatabaseColumn("NAME",ColumnDomain.String,Order=10,Size=64)]
public string Name
{
get { return ( string ) this["NAME"] ; }
set { this["NAME"] = value ; }
}
[DatabaseColumn("DESCRIPTION",ColumnDomain.String,Nullable=true,
Order=11,Size=1000)]
public string Description
{
get { return ( string ) this["DESCRIPTION"] ; }
set { this["DESCRIPTION"] = value ; }
}
}
Deriving from DataRow requires that you create a constructor that accepts a single parameter, a DataRowBuilder. This class is internal to the System.Data assembly.
Two properties are defined, Name and Description, each of these is attributed accordingly:
[DatabaseColumn("NAME",ColumnDomain.String,Order=10,Size=64)]
and
[DatabaseColumn("DESCRIPTION",ColumnDomain.String,Nullable=true,
Order=11,Size=1000)]
The Nullable property is set to true so that we are not forced to define a description column. The order number is set to 11, so that the name and description columns are always kept together in the generated schema.
The Author row is constructed as follows:
[DatabaseTable("AUTHOR")]
public class AuthorRow : GenericRow
{
public AuthorRow ( DataRowBuilder builder )
: base ( builder )
{
}
[DatabaseColumn("AUTHOR_ID",ColumnDomain.Long,Order=1)]
public long AuthorID
{
get { return ( long ) this["AUTHOR_ID"] ; }
set { this["AUTHOR_ID"] = value ; }
}
[DatabaseColumn("HIRE_DATE",ColumnDomain.DateTime,Nullable=true)]
public DateTime HireDate
{
get { return ( DateTime ) this["HIRE_DATE"] ; }
set { this["HIRE_DATE"] = value ; }
}
}
Here, we have subclassed the GenericRow class, and added in AuthorID and HireDate properties. Note the order number chosen for the AUTHOR_ID column - it is set to 1 to appear as the first column in the emitted table. So the table will be laid out as AUTHOR_ID, NAME, DESCRIPTION, and finally HIRE_DATE.
The BookRow class derives from GenericRow, so as to include the name and description properties. It adds BookID, PublishDate, and ISBN properties:
[DatabaseTable("BOOK")]
public class BookRow : GenericRow
{
public BookRow ( DataRowBuilder builder )
: base ( builder )
{
}
[DatabaseColumn("BOOK_ID",ColumnDomain.Long,Order=1)]
public long BookID
{
get { return ( long ) this["BOOK_ID"] ; }
set { this["BOOK_ID"] = value ; }
}
[DatabaseColumn("AUTHOR_ID",ColumnDomain.Long,Order=2)]
public long AuthorID
{
get { return ( long ) this["AUTHOR_ID"] ; }
set { this["AUTHOR_ID"] = value ; }
}
[DatabaseColumn("PUBLISH_DATE",ColumnDomain.DateTime,Nullable=true)]
public DateTime PublishDate
{
get { return ( DateTime ) this["PUBLISH_DATE"] ; }
set { this["PUBLISH_DATE"] = value ; }
}
[DatabaseColumn("ISBN",ColumnDomain.String,Nullable=true,Size=10)]
public string ISBN
{
get { return ( string ) this["ISBN"] ; }
set { this["ISBN"] = value ; }
}
}
Generating the SQL
It's time to generate the database schema from these classes. You could pipe the output to a text file by running the built.exe from a command prompt. The following class calls a function, OutputTable() for each type that we wish to create a database table for:
public class DatabaseTest
{
public static void Main()
{
OutputTable(typeof(AuthorRow));
OutputTable(typeof(BookRow));
}
public static void OutputTable(System.Type t)
{
....
....
}
}
We could utilize reflection to loop through each class in the assembly, check if it is derived from GenericRow, and output the classes automatically. To keep things simple, we have hard-coded the names of the tables to be generated: AuthorRow and BookRow.
Here is the OutputTable() method:
public static void OutputTable ( System.Type t )
{
object[] tableAttributes = t.GetCustomAttributes(
typeof(DatabaseTableAttribute),true);
if ( tableAttributes.Length == 1 )
{
Console.WriteLine ( "CREATE TABLE {0}" ,
((DatabaseTableAttribute)tableAttributes[0]).TableName ) ;
Console.WriteLine ( "(" ) ;
SortedList columns = new SortedList ( ) ;
foreach ( PropertyInfo prop in t.GetProperties ( ) )
{
object[] columnAttributes = prop.GetCustomAttributes(
typeof (DatabaseColumnAttribute),true);
if ( columnAttributes.Length == 1 )
{
DatabaseColumnAttribute dca = columnAttributes[0]
as DatabaseColumnAttribute ;
string dataType = ConvertDataType ( dca ) ;
columns.Add ( dca.Order, string.Format ( " {0,-31}{1,-20}{2,8}," ,
dca.ColumnName ,
dataType ,
dca.Nullable ? "NULL" : "NOT NULL" ) ) ;
}
}
foreach ( DictionaryEntry e in columns )
Console.WriteLine ( e.Value ) ;
Console.WriteLine ( ")" ) ;
Console.WriteLine ( "GO" ) ;
Console.WriteLine ( ) ;
}
}
This code reflects over the type passed in, and looks for the DatabaseTable attribute. If it is found, it writes a CREATE TABLE clause to the output, including the name of the table from the attribute. We then loop through all properties of the type to find any DatabaseColumn attribute. Any property that has this attribute will become a column in the generated table. The string representation of the column is constructed by calling the ConvertDataType() method, which will be shown shortly. This is stored within a sorted collection so that the columns are generated based on the value of the Order property of the attribute. Then we loop through the sorted list and write each value to the output. Finally, we add the closing bracket and a GO command which will instruct SQL Server to execute the batch of statements and create the table.
The ConvertDataType() function converts value from the ColumnDomain enumeration into a database specific data type. In addition, for string columns, we create the column representation to include the size of the column. Here is the implementation:
private static string ConvertDataType ( DatabaseColumnAttribute dca )
{
string dataType = null ;
switch ( dca.DataType )
{
case ColumnDomain.DateTime:
{
dataType = "DATETIME";
break ;
}
case ColumnDomain.Integer:
{
dataType = "INT";
break ;
}
case ColumnDomain.Long:
{
dataType = "BIGINT";
break ;
}
case ColumnDomain.String:
{
dataType = string.Format ( "VARCHAR({0})" , dca.Size ) ;
break ;
}
}
return dataType ;
}
For each member of the enumeration, we create a column string appropriate for SQL Server. The SQL emitted for the Author and Book classes from all this code looks like this:
CREATE TABLE AUTHOR
(
AUTHOR_ID BIGINT NOT NULL,
NAME VARCHAR(64) NOT NULL,
DESCRIPTION VARCHAR(1000) NULL,
HIRE_DATE DATETIME NULL
)
GO
CREATE TABLE BOOK
(
BOOK_ID BIGINT NOT NULL,
AUTHOR_ID BIGINT NOT NULL,
NAME VARCHAR(64) NOT NULL,
DESCRIPTION VARCHAR(1000) NULL,
PUBLISH_DATE DATETIME NULL,
ISBN VARCHAR(10) NULL
)
GO
This SQL can be run against an empty or pre-existing SQL Server database to create the tables.