DevTrain

Autor: Klaas Wedemeyer

ORM Teil 1: Object Relation Mapper selber schreiben ? mit .net Reflection ganz einfach

Viele Anwendungen greifen auf eine Datenbank zu. Daher finden sich in den Anwendungen immer wieder die gleichen Sql Statements. Immer wieder werden die gleichen Funktionalitäten implementiert: Statement erzeugen, Daten kopieren, Kollisionsschutz, usw. In der letzten Zeit sind daher Object Relation Mapper (ORM) immer beliebter geworden. Sie kapseln den Zugriff auf die Datenbank, damit der Anwender nur mit Objekten und nicht mit Sql arbeiten muß. Es gibt am Markt viele ORM Versionen. Einige sind sehr teuer, andere sind umständlich, können nicht das was man will oder sind schlecht dokumentiert. Und selber schreiben? Mit .net Reflection ist das gar nicht schwer.

Was ist Reflection?
.net stellt für jeder Klasse beschreibende Informationen zur Verfügung. Man kann fast alles über die Klasse rauskriegen: Funktionen, Variablen, Properties, Typen und Attribute. Attribute sind Zusatzinfos, die man an Klassen und Member hängen kann. Diese sind ideal geeignet, um die Datenbankinformationen zu verwalten. Attribute sind einfache Klassen, die von Attribute abgeleitet sind und durch das Attribut AttributeUsage festlegen, was sie beschreiben sollen.
Als erstes brauchen wir ein Attribute welches eine Klasse für den Mapper zur Verfügung stellt. In diesem Attribute wird auch der Tabellenname festgelegt.

[AttributeUsage(AttributeTargets.Class)]
public class DataTableAttribute : Attribute
{
    public readonly string TableName = "";
    public DataTableAttribute(string TableName)
    {
        this.TableName = TableName;
    }
}

In einem weiteres Attribute wird einer Membervariablen einer Spalte zugewiesen.

[AttributeUsage(AttributeTargets.Field)]
public class DataColumnAttribute : Attribute
{
    public readonly string ColumnName = "";
    public DataColumnAttribute(string ColumnName)
    {
        this.ColumnName = ColumnName;
    }
}

Der Typ der Spalte muss dem Typ des Members entsprechen. Eine Convertierung kann hinzugefügt werden, ist aber leider sehr inperformant.
Jetzt brauchen wir ein Attribute für den Primary Key. Falls die Tabelle ein mehrteiligen Schlüssel hat, kann man die Reihenfolge mit dem Index bestimmen.

[AttributeUsage(AttributeTargets.Field)]
public class PrimaryKeyAttribute : Attribute
{
    public readonly int Index = 1;
    public PrimaryKeyAttribute(){}
    public PrimaryKeyAttribute(int Index)
    {
        this.Index = Index;
    }
}

Für ein optimistisches Loggen kann man jedem Objekt einen Timestamp zuordnen. Erlaubt sind die Typen int, DateTime und Guid.

[AttributeUsage(AttributeTargets.Field)]
public class TimeStampAttribute : Attribute
{}

So würde ein Objekt mit diesen Attributen aussehen

[DataTableAttribute("Customers")]
class Customer
{
    [DataColumnAttribute("Id"), PrimaryKeyAttribute()]
    private Guid mId = Guid.NewGuid();
    public Guid Id
    {
        get { return mId; }
    }

        [DataColumnAttribute("Name")]
        private string mName = "";
        public string Name
        {
            get { return mName; }
            set { mName = value; }
        }

    [DataColumnAttribute("Age")]
    private int mAge = 0;
    public int Age
    {
        get { return mAge; }
        set { mAge = value; }
    }

    [DataColumnAttribute("Birthday")]
    private DateTime mBirthday = new DateTime(1, 1, 1);
    public DateTime Birthday
    {
        get { return mBirthday; }
        set { mBirthday = value; }
    }

    [DataColumnAttribute("TimeStamp"), TimeStampAttribute()]
    private int mTimeStamp = 0;
}

Jetzt haben wir alle Informationen um ein Objekt aus der Datenbank zu lesen und wieder zu schreiben.
Ich habe meinen ORM für den SQL Server geschrieben. Mit leichten Veränderungen kann man Ihn für jeden relationale Datenbank anpassen.

Das Select Statement
Zuerst muss man den Tabellennamen lesen. Dazu such man in dem Type der Klasse nach dem richtigem Attribute:

protected string GetTableName(Type ObjectType)
{
    object[] DTAttributes = ObjectType.GetCustomAttributes(typeof(DataTableAttribute), false);
    DataTableAttribute DTAttr = (DataTableAttribute)DTAttributes[0];
    string TableName = DTAttr.TableName.Trim();
    return TableName;
}

Für die Spalten muss man in den FieldInfos des Typs nachsehen. Wenn das Feld nicht gespeichert werden soll, wird nichts zurück gegeben:

protected string GetColumnName(FieldInfo Info)
{
    object[] DCAttributes = Info.GetCustomAttributes(typeof(DataColumnAttribute), false);
    if (DCAttributes.Length == 0) return "";
    DataColumnAttribute DCAttribute = (DataColumnAttribute)DCAttributes[0];
    return DCAttribute.ColumnName.Trim();
}

Jetzt kann man das SELECT zusammen basteln:

public object[] Select(Type ObjectType, string Querry, IDataParameter[] Parameters, System.Data.IDbConnection Connection)
{
    IDataReader Reader = null;
    ArrayList Result = new ArrayList();

Beim Lesen der Felder sollen Öffentliche und Private verwendet werden 

    FieldInfo[] Infos = ObjectType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

    IDbCommand Command = Connection.CreateCommand();
    string TableName = GetTableName(ObjectType);
    string Columns = "");

Dann müssen alle Felder zusammengefügt werden

    foreach (FieldInfo Info in Infos)
    {
        string ColumnName = GetColumnName(Info);
        if (ColumnName != "")
        {
            if (Columns != "")
            Columns += ", "
            Columns.Add("[" + ColumnName + "]");
        }
    }

Daraus das Sql Statement erstellen

    string Sql = "SELECT " + Columns + " FROM [" + TableName + "]";
                    if (Querry.Trim() != "")
                        Sql += " WHERE " + Querry.Trim();
                    Command.CommandText = Sql;

Falls der Benutzer für die Querry Parameter verwendet hat, müssen diesen noch hinzugefügt werden.

    if (Parameters != null)
        foreach (IDataParameter Parameter in Parameters)
            Command.Parameters.Add(Parameter);

Und los get?s

    Reader = Command.ExecuteReader();

Dann wird für jeden Treffer ein Objekte erzeugt

    while (Reader.Read())
    {
        object NewObject = Activator.CreateInstance(ObjectType);

und mit den Werten gefüllt

        foreach (FieldInfo Info in Infos)
        {
            string ColumnName = GetColumnName(Info);
            if (ColumnName != "")
            {
                int index = Reader.GetOrdinal(ColumnName);
                object Value = Reader.GetValue(index);
                Info.SetValue(NewObject, Value);
            }
        }
        Result.Add(NewObject);
    }
    Reader.Close();
    return (object[]) Result.ToArray(ObjectType);
}

Fertig ist das Select. Aufgerufen wird es durch

IDbConnection con = GetConnection();
con.Open();
IObjectRelationMapper orm = new ORMapperSqlServer();
Customer[] Customers = (Customer[])orm.Select(typeof(Customer), "Name LIKE ?Schmidt?", null, con);
con.Close();

// Anzeigen
foreach (Customer cus in Customers)
{
    ListViewItem item = new ListViewItem(cus.Name);
    item.Tag = cus;
    listView1.Items.Add(item);
}

Das UPDATE Statement
Das INSERT, UPDATE, und DELETE sind sich sehr ähnlich. Ich möchte deshalb nur das UPDATE beschreiben, die anderen Funktionen ergeben sich daraus.

public object Update(object DataObject, System.Data.IDbConnection Connection)
{
    Type ObjectType = DataObject.GetType();
    FieldInfo[] Infos = ObjectType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);

    IDbCommand Command = Connection.CreateCommand();
    string TableName = GetTableName(ObjectType);
    string Columns = "";
    string Querry = "";

Erstmal die Spalten lesen

    foreach (FieldInfo Info in Infos)
    {
        string ColumnName = GetColumnName(Info);
        int PKIndex = GetPrimaryKeyIndex(Info);
        bool TimeStamp = IsTimeStamp(Info);

Der Primary Key wird in das WHERE eingefügt

       if ( PKIndex > 0)
        {
            if (Querry!= "") Querry += " AND ";
                Querry += ("[" + ColumnName + "] = @PK_" + ColumnName);
            object Value = Info.GetValue(DataObject);
            IDbDataParameter Par = Command.CreateParameter();
            Par.ParameterName = "@PK_" + ColumnName;
            Par.Value = Value;
            Command.Parameters.Add(Par);
        }

 Der Timestamp wird auch in das WHERE eingefügt

        if (TimeStamp)
        {
            if (Querry!= "") Querry += " AND ";
            Querry += ("([" + ColumnName + "]=@TS_" + ColumnName + " OR [" + ColumnName + "] IS NULL)");
            object Value = Info.GetValue(DataObject);
            IDbDataParameter Par = Command.CreateParameter();
            Par.ParameterName = "@TS_" + ColumnName;
            Par.Value = Value;
            Command.Parameters.Add(Par);
        }

Und jetzt die Daten

        if (ColumnName != "")
        {
            if (Columns!= "") Columns += ", ";
            Columns.Add("[" + ColumnName + "]=@Col_" + ColumnName);
            IDbDataParameter Par = Command.CreateParameter();
            Par.ParameterName = "@Col_" + ColumnName;
            object Value = Info.GetValue(ObjectCopy);

Der TimeStamp wird neu berechnet

            if (TimeStamp)
            {
                if (Info.FieldType == typeof(int))
                Value = ((int)Info.GetValue(ObjectCopy)) + 1;
                else if (Info.FieldType == typeof(Guid))
                Value = Guid.NewGuid();
                else if (Info.FieldType == typeof(DateTime))
                Value = DateTime.Now;
            }

            Par.Value = Value;
            Command.Parameters.Add(Par);
        }
    }

Jetzt nur noch das SQL Statement zusammen setzten

    string Sql = "UPDATE [" + TableName + "] SET " + Columns + " WHERE " + Querry;
    Command.CommandText = Sql;

Und ausführen

    int nRows = (int)Command.ExecuteNonQuery();

Wenn sich das Update auf keine Zeile ausgewirkt hat, wurde die Zeile vorher gelöscht oder der TimeStamp geändert.

    if (nRows != 1)
        throw new Exception("Das Objekt wurde nach dem Lesen geändert oder gelöscht");
}

Und schon kann man Zeilen ändern:

IDbConnection con = GetConnection();
con.Open();
IObjectRelationMapper orm = new ORMapperSqlServer();
orm.Update(myCustomerItem.Tag, con);
con.Close();

Zu beachten ist, daß sich der Timestamp nach dem Speichern geändert hat. Man muss das Objekt neu lesen um es weiterzuverarbeiten oder sich unter www.KlaasWedemeyer.de die vollständige Version runterladen. Hier findet Ihr auch das INSERT und das DELETE Statement und eine kleine Demoanwendung.
Im zweiten Teil werde ich zeigen, wie man mit diesem ORM Joins über mehrere Tabellen erzeugt.
Im dritten Teil stelle ich Euch eine Möglichkeit vor, wie die Anwendung die Tabellen in der DB selbstständig erzeugt und verwaltet.

Bis dahin,
Klaas Wedemeyer


Erfasst am: 20.04.2006 - Artikel-URL: http://www.devtrain.de/news.aspx?artnr=974
© Copyright 2003 ppedv AG - http://www.ppedv.de