Een Doe-Het-Zelf Object Relational Mapper
De universele datalaag voor elk project
Hoe bouw je een universele object relational mapper in C# met het NET framework 2.0? Dankzij features als Generics, Reflection en het Provider Indepedent Model is dat veel minder ingewikkeld dan het op het eerste gezicht lijkt.
Object relational mapping verzorgt de transformatie van gegevens tussen twee incompatibele systemen: object oriëntatie en relationele databases. Object relational mapping is dus kort gezegd een manier om objecten via een transformatieslag in een database op te slaan en weer op te halen. De representatie van het object binnen een database is een “gewoon” record met veldwaarden. De representatie van een record binnen object oriëntatie is een object met property waarden.
Relationele databases zijn bijzonder goed in het organiseren en (snel) presenteren van relationele gegevens. Object oriëntatie geeft de ontwikkelaar de mogelijkheid een overzichtelijke structuur in een applicatie aan te brengen. Via ORM wordt getracht het beste van beide werelden zo naadloos mogelijk te combineren.
Via ORM wordt getracht de relationele en de OO wereld te combineren
De mapper verzorgt het uitvoeren van SQL commando’s. Een ontwikkelaar hoeft zich alleen in uitzonderingsgevallen nog maar druk te maken over querying. In veel mappers is het schrijven van SQL queries niet eens meer mogelijk; in de plaats daarvan is vaak een set van mapper-specifieke query-functies beschikbaar
Welke mappers kennen we?
Een bekende object relational mapper voor de .NET omgeving is NHibernate. Deze mapper ondersteunt tal van databases maar is niet alleen daarom zeer populair. NHibernate heeft uitgebreide mogelijkheden om het transformatiegedrag naar eigen hand te zetten. Naast deze mapper zijn er tal van andere mappers beschikbaar voor .NET. Microsoft zal op dit gebied met de, in ontwikkelstadium verkerende, Language Integrated Query (LINQ) ongetwijfeld ook een grote rol gaan spelen.
Zelf een mapper bouwen?
Hoe vaak worden OR-mappers gebruikt binnen projecten van organisaties? Naar mijn idee te weinig. Tijdsbesparing en de daarbij komende kostenreductie zijn in ieder geval goede drijfveren voor het gebruik ervan. Dan moet het toch de onbekendheid zijn met de onderliggende methodiek en vaak ook de angst om het beheer van de data te laten uitvoeren door een open source library zonder ondersteuningsgarantie. Het zelf bouwen van een mapper levert daarom voordelen op. Daarbij komt nog dat eisen die de omgeving stelt, gemakkelijk in zo’n mapper zijn door te voeren. Bovendien is het met beperkte tijd heel goed mogelijk een goed functionerende mapper te bouwen die op de meest belangrijke punten niet onderdoet voor third-party mappers. Het ontwikkelen van een OR-mapper is niet ingewikkeld. Een beperkte kennis van reflection is echter onontbeerlijk. Reflection is een manier om tijdens runtime de structuur en data van code te inspecteren en eventueel te beïnvloeden.
Eisen en wensen
Wil je als ontwikkelaar gegevens van objecten vast leggen dan is vaak waar en hoe van ondergeschikt belang. De opslag van gegevens moet uiteraard wel goed gebeuren. Het is niet realistisch om te denken dat deze vlieger in alle gevallen op gaat maar lijkt een goed uitgangspunt. Op basis van deze gedachte een aantal specificaties:
- Veelzijdig: het kunnen communiceren via zo veel mogelijk databasetechnologieën en dus met zoveel mogelijk databases. Dit betekent ondersteuning voor OleDb, Odbc, Sql(Client) en daarmee toegang tot databronnen als SQL Server, Access, MySQL, SQL Compact Edition, flat files etc.
- Simpel en accuraat: de transformatie moet volledig via code te regelen zijn. Alles wat in code te regelen is, is later ook weer in een configuratiebestand te stoppen. De keuze is aan de ontwikkelaar. In normale situaties moet de mapper nauwelijks extra, maar zeker geen ingewikkelde code vereisen.
- Flexibel en krachtig: de mogelijkheid tot het gebruik van SQL queries bij de selectie van data. SQL blijft tot nu toe de meeste krachtige data zoekmethode. De in de mapper gebruikte SQL commando’s zoals Delete, Insert en Update moeten aanpasbaar zijn. Zaken die niet direct noodzakelijk lijken voor de transformatie worden niet geïmplementeerd.
- Cacheability: mogelijkheid tot het mappen van en naar DataTables. Dit is handig voor de bouw van offline-clients of het cachen van gegevens om de belasting van een DBMS te beperken.
- Zonder neveneffecten: de mapper mag geen invloed hebben op te persisteren objecten en klassen. De klassen moet niet naar de hand van de mapper hoeven te worden gezet. Denk hierbij aan eisen op het gebied van base classes, interfaces of custom attributes. Wel moet de creatie van een object plaats kunnen vinden binnen de mapper. Het is dan ook een eis, dat een de klasse die gemapt moet worden, een parameterloze constructor bevat.
De oplossing
Figuur 1 geeft de universele mapper schematisch weer. Een implementatie hiervan is beschikbaar via de link aan het einde van dit artikel.

Fig. 1: Een universele mapper
Als basis krijgt de mapper de generic klassen DataTableMapper en DbMapper. De DataTableMapper klasse verzorgt de transformatie van en naar een DataTable. De DbMapper klasse verzorgt de transformatie van en naar een database. Per te mappen klasse dient een mapper-instantie te worden gecreëerd. Voor de diverse voorbeelden wordt de klasse uit listing 1 gebruikt.
public class Person
{
private int _Id;
private string _FirstName;
private string _LastName;
private DateTime? _BirthDate;
public int Id
{
get { return _Id; }
set { _Id = value; }
}
public string FirstName
{
get { return _FirstName; }
set { _FirstName = value; }
}
public string LastName
{
get { return _LastName; }
set { _LastName = value; }
}
public DateTime? BirthDate
{
get { return _BirthDate; }
set { _BirthDate = value; }
}
}
Listing 1: De klasse Person

Fig. 2: Voorbeelddata voor de Person klasse uit listing 1
DataTableMapper klasse
De DataTableMapper klasse bevat 3 properties:
- DataTable, type DataTable
- PrimaryKeyPropertyNames, type string[]
- PropertyFieldTranslations, type Dictionary
De property DataTable wordt gevuld met een DataTable zoals te zien in figuur 2. In de property PrimaryKeyPropertyNames worden de namen van alle properties die onderdeel uitmaken van de primaire sleutel meegegeven. In het voorbeeld is dit de “Id” property van Person. De PropertyFieldTranslations dictionary bevat een lijst met propertynamen en de daaraan gerelateerde veldnamen in de DataTable. De key van het KeyValuePair bevat de propertynaam van het object en de value de veldnaam uit de DataTable. Propertynamen die exact overeenkomen met veldnamen hoeven niet te worden opgenomen. Voor de voorbeeldklasse kan de dictionary daarom leeg worden gelaten.
DataTableMapper PersonDTMapper =
new DataTableMapper(dt, new string[] {"Id"});
Listing 2: Het creëren van een mapper voor de Person klasse
De DataTableMapper bevat de volgende functies:
- GetAll voor het ophalen van objecten uit de DataTable.
- Exists om vast te stellen of een object al in de DataTable bestaat
- Save verzorgt het opslaan van een object in de DataTable; intern wordt bepaald of dit een Insert of Update moet zijn.
- Delete verwijderd een object uit de DataTable.
In listing 3 vind je een voorbeeld van het gebruik van de diverse functies.
Person P;
List Persons;
//Alle personen ophalen
Persons = PersonDTMapper.GetAll();
//Met RowFilter
Persons = PersonDTMapper.GetAll("FirstName = 'Ton'");
P = PersonDTMapper.Exists(Persons[0]);
P.FirstName = "Tony";
//Sla de gewijzigde persoon op in de DataTable
PersonDTMapper.Save(P);
//Verwijder de persoon
PersonDTMapper.Delete(P);
Listing 3: Het gebruik van de DataTableMapper
Onder de motorkap van de DataTableMapper
In listing 4 is de implementatie van de GetAll methode te zien. Voor het type T (Person) wordt een generic list aangemaakt. In de for-lus wordt voor elke DataRow een Person object gecreëerd. Hier wordt duidelijk waarom de aan de DataTableMapper meegegeven klasse een parameterloze constructor dient te bevatten. Via de generic method Activator.CreateInstance wordt er een instantie van het type T (Person) gecreëerd.
public List GetAll()
{
List result = new List();
foreach (DataRow row in DataTable.Rows)
{
T Object = Activator.CreateInstance();
MapDataRowToObject(row, Object);
result.Add(Object);
}
return result;
}
protected virtual void MapDataRowToObject(
DataRow Row, T Object)
{
foreach (KeyValuePair Pair
in PropertyInfos)
{
string FieldName = MapFieldName(Pair.Key);
//Does a column with the name fieldname exist
//within the datarow
if (Row.Table.Columns.IndexOf(FieldName) >= 0)
{
//Do a conversion of the field type
// to the property type
object Value = Row[FieldName];
System.Type TypeToConvertTo =
GetPropertyConversionType(Pair.Key);
if (Value == DBNull.Value)
Value = null;
else
Value = TypeConverter.Convert(
Value, TypeToConvertTo);
//indexers are not supported
Pair.Value.SetValue(Object, Value, null);
}
}
}
internal Dictionary PropertyInfos
{
get
{
if (_PropertyInfos == null)
{
_PropertyInfos =
new Dictionary();
foreach (
PropertyInfo pi in typeof(T).GetProperties())
{
_PropertyInfos.Add(pi.Name, pi);
}
}
return _PropertyInfos;
}
}
Listing 4
De interne functie MapDataRowToObject verzorgt het vullen van de door de Activator gecreëerde instantie. Om wat dieper in te kunnen gaan op deze methode eerst iets over reflection. De property PropertyInfos haalt uit het meegegeven type (Person) per property alle property-gegevens op. Deze gegevens worden in een generic dictionary geplaatst. De code typeof(T) retourneert een System.Type waarmee allerlei gegevens op kunnen worden gevraagd van het betreffende type. Met de System.Type.GetProperties functie wordt opgevraagd welke properties er zijn in het betreffende type. Het resultaat is per property een PropertyInfo object waarin alles over een property kan worden gevonden. De PropertyInfos dictionary met PropertyInfo objecten wordt gebruikt in de MapDataRowToObject functie. In de for-lus wordt m.b.v. de PropertyInfo objecten de waarde van elke property opgevraagd. Pair.Key bevat een propertynaam. MapFieldName functie zoekt uit welke kolomnaam uit de DataTable hoort bij de propertynaam. Kan er een link worden gelegd tussen een kolomnaam in de DataTable en de propertynaam, dan wordt de waarde uit het veld ingelezen.
Het is mogelijk dat het type van een veld anders is dan het type van de gerelateerde property. Dit is geen probleem als het niet om exotische verschillen gaat. De functie GetPropertyConversionType bepaalt van welk type de property is. Deze functie houdt ook nog eens rekening met Nullable types. De speciaal voor dit doel geschreven klasse TypeConverter verzorgt de conversie van het ene type naar het andere type. Vooral conversies tussen IConvertible types (bijv. Int, String, Char, DateTime etc..) zijn gemakkelijk. Met de .NET functie System.Convert.ChangeType(Value, TypeToConvertTo) is een typeconversie simpel uitgevoerd. Conversie van bijvoorbeeld Guid naar een string of andersom is specifiek in deze TypeConverter geïmplementeerd. Een Guid implementeert IConvertible namelijk niet. Als de waarde is geconverteerd kan deze via reflection m.b.v. de PropertyInfo.SetValue methode worden weggeschreven in de property.
Het wegschrijven van objectgegevens (Save) naar een DataRow gebeurt via de functie MapObjectToDataRow.
protected virtual void MapObjectToDataRow(
T Object, DataRow Row)
{
foreach (KeyValuePair Pair
in PropertyInfos)
{
string FieldName = MapFieldName(Pair.Key);
//Does a column with the name FieldName exist?
if (Row.Table.Columns.IndexOf(FieldName) >= 0)
{
//Do the conversion from propertytype to fieldtype
object Value = Pair.Value.GetValue(Object, null);
Value = TypeConverter.Convert(
Value, Row.Table.Columns[FieldName].DataType);
Row[FieldName] = Value;
}
}
}
Listing 5: De MapObjectToDataRow methode.
Zoals in de MapDataRowToObject methode worden ook hier weer alle beschikbare properties afgelopen. Met behulp van de functie PropertyInfo.GetValue(Pair.Value) wordt de waarde van een property uitgelezen. De door de TypeConverter geconverteerde waarde wordt daarna weggeschreven in het gerelateerde veld van de DataRow. Om specifieke DataRows op te halen via bijvoorbeeld de Exists(T Object) of GetAll(string RowFilter) functie wordt intern een DataView met gevulde RowFilter property gebruikt.
De DbMapper klasse
De DbMapper generic klasse verzorgt de transformatie van objecten van en naar een database. De DbMapper klasse maakt voor het ophalen van gegevens gebruik van de DataTableMapper klasse. De database mapper is gebaseerd op het Provider Independent Model wat is geïntroduceerd in .NET framework 2.0. Praktisch betekent dit dat door het providertype (System.Data.Odbc, System.Data.Oledb of System.Data.SqlClient) aan een ProviderFactory mee te geven de benodigde Connection, Command, Parameter, DataAdapter etc. objecten automatisch door de factory van het goede type worden gecreëerd. Dit betekent dat de mapper kan werken met alle databases die door het .NET framework worden ondersteund.
In listing 6 is de creatie van de DbMapper voor de Person klasse te zien.
DbProviderFactory DbProviderFactory =
DbProviderFactories.GetFactory(“System.Data.SqlClient”);
string ConnectionString =
ConfigurationManager.AppSettings[
"SqlConnectionString"];
PersonMapper = new DbMapper(DbProviderFactory,
ConnectionString, "tblPerson_per");
Listing 6
Als eerste wordt er een ProviderFactory gecreëerd voor SQL Server (SqlClient). Vervolgens wordt de connection-string naar de database opgehaald. In de constructor worden beide meegegeven met de naam van de primaire tabel (degene die de primary key bevat). Wordt de tabelnaam niet opgegeven, dan wordt aangenomen dat deze gelijk is aan de naam van de klasse (dus: Person). Er kan een primary key worden opgegeven zoals bij de DataTableMapper; dit is meestal niet noodzakelijk omdat de primary key over het algemeen via het databaseschema kan worden vastgesteld. Ook is het mogelijk om net zoals met de DataTableMapper een translatiedefinitie mee te geven.
Voor het ophalen van objecten met de DbMapper verwacht de mapper een gewone SQL query. Om een enkele of een verzameling Person objecten op te halen is de code uit listing 7 toereikend.
//GetAll
List Persons = PersonMapper.GetAll(
string.Format("SELECT * FROM {0}",
PersonMapper.PrimaryTableName));
//identiek hieraan is
List Persons = PersonMapper.GetAll();
//GetSingle
string PlaceHolderId;
dbParameter ParamId = PersonMapper.GetParameter(
"Id", IdValue, out PlaceHolderId));
Person p = PersonMapper.GetSingle(
string.Format("SELECT * FROM {0} WHERE Id = {1}",
PersonMapper.PrimaryTableName, PlaceHolderId),
ParamId);
Listing 7
Indien gebruik wordt gemaakt van parameters moeten deze door de factory via de DbMapper instantie worden gecreëerd. Er wordt rekening gehouden met de benaming van de parameter placeholders. ‘@ParameterName’ wordt de syntax als gebruik wordt gemaakt van SQL Server terwijl binnen OleDb ‘?’ wordt gebruikt als placeholder. Het opslaan en verwijderen werkt hetzelfde als via de DataTableMapper klasse. Daarnaast worden additioneel transacties ondersteund. Voor elke actie (Insert, Update en Delete) is een Before en een After event beschikbaar waarbij Connection, Command en Transaction toegankelijk zijn. Het is mogelijk het gegenereerde Insert-, Update- en DeleteCommand naar eigen inzicht aan te passen via gelijknamige public properties. Het is daardoor onder andere mogelijk stored procedures te gebruiken.
De mapper is ruim inzetbaar dankzij Reflection, Generics en het Provider Independent Model
Bij het verwijderen of opslaan van objecten wordt door de DbMapper geen gebruik gemaakt van DataTableMapper functionaliteit. De DbMapper beheert geen DataTable met originele waarden waardoor modificaties niet kunnen worden vergeleken en dus niet via een DataAdapter kunnen worden doorgevoerd. Om wel gebruik te kunnen maken van deze functionaliteit en daarmee dus een stuk cacheability mogelijk te maken zijn de volgende twee members beschikbaar: GetDataTableMapper en CommitDataTableMapper.
Met de GetDataTableMapper functie wordt een DataTableMapper aangemaakt op basis van de opgegeven SQL query en parameters. Manipulaties in objecten worden vastgelegd in de DataTable. Op elk gewenst moment kunnen alle wijzigingen via de methode CommitDataTableMapper worden opgeslagen in de via de DbMapper gedefinieerde database. De gegevens worden intern via een DataAdapter daadwerkelijk in de database weggeschreven.
Onder de motorkap van de DbMapper
De GetAll en GetSingle functies binnen de DbMapper maken gebruik van een tijdelijke DataTableMapper om de mapping van relationele data naar object te bewerkstelligen. Het Update-, Insert en Delete command worden via reflection opgebouwd waarbij rekening wordt gehouden met de verschillende databases die gebruikt kunnen worden. Hierbij spelen het DataAdapter en de CommandBuilder object een belangrijke rol.
De CommandBuilder, die wordt gevuld met behulp van de DataAdapter, verzorgt formattering van parameter-placeholders en quoting van de tabel- en veldnamen. Voor SQL Server geldt bijvoorbeeld “[tblPerson_per]” terwijl dit binnen OleDb gewoon als “tblPerson_per” wordt genoteerd. Het DataTableSchema kan worden opgehaald met de functie DataTableMapper.FillSchema en helpt bij het vaststellen van de primary keys en het achterhalen van identity kolommen.
De CommandBuilder, in combinatie met een DataAdapter gevuld met een SELECT-query, resulteert in kant en klare SQL-commando’s. Deze Commando’s zijn specifiek bedoeld voor het gebruik in combinatie met DataTables. Binnen de mapper kan daarom enkel het door de CommandBuilder gegenereerde Insert commando gebruikt worden, en het Update en Delete commando niet. Deze houden namelijk rekening met de oude waardes in een DataRow die in de huidige implementatie van de mapper voor het object niet beschikbaar zijn. Het Update en Delete commando worden in de mapper zelf gegenereerd. Deze commando’s worden opgebouwd inclusief de parameters. De parameters worden pas gevuld op het moment dat gegevens moeten worden opgeslagen. In de FillParameterValues methode gebeurt het vullen van de parameterwaarden met behulp van de PropertyInfo.GetValue functie. De TypeConverter verzorgt zoals bij de DataTableMapper het oplossen van eventuele discrepanties tussen database-veldtype en object-propertytype.
private void FillParameterValues(
DbCommand Command, T Object)
{
foreach (DbParameter param in Command.Parameters)
{
//Determine the by the database expected type
System.Type TableType =
_DataTableSchema.Columns[param.SourceColumn].
DataType;
object ObjectPropertyValue =
PropertyInfos[MapToPropertyName(
param.SourceColumn)].GetValue(Object, null);
if (ObjectPropertyValue == null)
param.Value = DBNull.Value;
else
param.Value =
TypeConverter.Convert(
ObjectPropertyValue, TableType);
}
}
Listing 8
Bij het uitvoeren van het Insert command wordt er rekening gehouden met identity kolommen in de database. In listing 9 is te zien hoe de automatisch gegenereerde waarde direct na Insert in de juiste property van het object wordt weggeschreven.
//Retrieve the identity, if necessary
if (IdentityColumn != null)
{
DbCommand comm = this.GetCommand();
comm.CommandText = "SELECT @@Identity";
comm.Connection = InsertCommand.Connection;
comm.Transaction = InsertCommand.Transaction;
object Identity = comm.ExecuteScalar();
PropertyInfo property =
PropertyInfos[
MapToPropertyName(IdentityColumn.ColumnName)];
object PropertyValue =
TypeConverter.Convert(
Identity, property.PropertyType);
property.SetValue(Item, PropertyValue, null);
}
Listing 9
Conclusie
De mapper maakt optimaal gebruik van .NET framework features en is ruim inzetbaar dankzij Reflection, Generics en het Provider Independent Model. Er was ongeveer 8 uur nodig om deze mapper te vertalen in een praktische oplossing. Ben je niet van plan om van de grond af zelf een mapper te bouwen of zijn na dit artikel zaken nog niet helemaal duidelijk, dan is de volledige code van de mapper, inclusief een demo, te downloaden via een link aan het einde van dit artikel.
Referenties