Wie gevestigde bedrijfsoftware in Delphi beheert, kent dit spanningsveld: enerzijds wil je gestructureerde domeinobjecten en duidelijke lagen, anderzijds bestaan er Datasets, Variants, CSV-imports, interface-payloads of een REST-API die ‚op de een of andere manier‘ op objecten gemapt moeten worden. Precies hier kom je snel uit bij Delphi RTTI voor mapping zonder magie: mapping via reflection (RTTI = Run-Time Type Information, type-informatie tijdens uitvoering), maar zodanig dat het traceerbaar blijft, goed te debuggen is en niet stiekem van conventies of naamsgrapjes afhankelijk is.
De kern: ‚magie‘ ontstaat meestal niet door RTTI op zich, maar door impliciete regels. Als mappingregels daarentegen expliciet in attributen staan, conversies gecentraliseerd zijn en fouten een duidelijke oorzaak benoemen, wordt RTTI een hulpmiddel in plaats van een verrassing.
Waarom RTTI-mapping in Delphi vaak faalt
RTTI-gebaseerd mapping faalt in reële systemen zelden aan het idee zelf, maar aan randvoorwaarden:
- Legacy-gegevensvormen: Null/Empty/0 zijn niet duidelijk onderscheiden, veldtypen wisselen, strings bevatten ‚N/A‘.
- Sluipende conventies: ‚veld heet als property‘ werkt tot de eerste alias, join of hernoemde propertynaam.
- Moeilijk te debuggen: Als een mapper ‚gewoon niets instelt‘, ontbreekt later de oorzaak. In productie is dat vergif.
- Performance-mythen: RTTI wordt generaliserend als ‚traag‘ bestempeld, terwijl het probleem meestal ontbrekend cachen is.
Een houdbare aanpak zou daarom (1) expliciete mapping-metagegevens hebben, (2) conversie en null-semantie duidelijk behandelen, (3) fouten en debug-uitvoer leveren en (4) RTTI-informatie cachen.
Delphi RTTI voor mapping zonder magie: ontwerpprincipes
Het volgende patroon is bewust ’saai‘ in de beste zin: regels zijn zichtbaar, bijwerkingen beperkt, en het kan stapsgewijs in bestaande modules worden geïntroduceerd.
- Attributen in plaats van naamconventie: een property krijgt een attribuut dat de bronkolom benoemt.
- Opt-in: alleen gemarkeerde properties worden ingesteld. Geen verrassingen door ‚alle gepubliceerde properties‘.
- Conversie op één plek: Variant/String/Integer/Boolean/Enum/Nullable worden centraal gemapt.
- Debugmodus: optioneel wordt gelogd welke velden zijn ingesteld/overgeslagen — inclusief reden.
- RTTI-caching: de duurste onderdelen (propertylijst, attribuutevaluatie) worden per type voorbereid.
Broncodevoorbeeld: Attribuut-mapping met RTTI, caching en debug
Het fragment zet een rij (bijv. uit BDE-vervanging met native koppeling via TDataSet) om naar een object. In plaats van de mapper vast te koppelen aan TField, gebruiken we een kleine reader-interface. Dat is in de praktijk waardevol, omdat u later dezelfde logica ook voor JSON, INI, CSV of API-responses kunt gebruiken.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Expliciet mapping: Property <- bronnaam
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Kleine abstractie: waarde leveren + onderscheid tussen bestaan en NULL
IValueReader = interface
['{7D1E5864-7D3A-4D30-BD1C-0A94F7E6C0EF}']
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
TRttiMapOptions = set of (moDebug, moIgnoreMissing, moIgnoreNull);
ERttiMappingError = class(Exception);
TRttiMapper = class
private
type
TPropMap = record
Prop: TRttiProperty;
SourceName: string;
end;
TTypeCache = class
Props: TArray<TPropMap>;
end;
private
class var FCache: TObjectDictionary<PTypeInfo, TTypeCache>;
class var FCacheLock: TObject;
class function GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache; static;
class function FindMapFromAttr(const AProp: TRttiProperty): string; static;
class procedure SetPropertyValue(const AInstance: TObject; const AProp: TRttiProperty;
const AValue: Variant); static;
class function VariantToBoolean(const V: Variant): Boolean; static;
class function VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer; static;
public
class constructor Create;
class destructor Destroy;
class procedure MapToObject(const AReader: IValueReader; const ATarget: TObject;
const AOptions: TRttiMapOptions = [moIgnoreMissing]); static;
end;
implementation
{ MapFromAttribute }
constructor MapFromAttribute.Create(const AName: string);
begin
inherited Create;
FName := AName;
end;
{ TRttiMapper }
class constructor TRttiMapper.Create;
begin
FCache := TObjectDictionary<PTypeInfo, TTypeCache>.Create([doOwnsValues]);
FCacheLock := TObject.Create;
end;
class destructor TRttiMapper.Destroy;
begin
FCache.Free;
FCacheLock.Free;
end;
class function TRttiMapper.FindMapFromAttr(const AProp: TRttiProperty): string;
var
Attr: TCustomAttribute;
begin
Result := '';
for Attr in AProp.GetAttributes do
if Attr is MapFromAttribute then
Exit(MapFromAttribute(Attr).Name);
end;
class function TRttiMapper.GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache;
var
Ctx: TRttiContext;
RType: TRttiType;
P: TRttiProperty;
L: TList<TPropMap>;
Src: string;
M: TPropMap;
begin
TMonitor.Enter(FCacheLock);
try
if FCache.TryGetValue(ATypeInfo, Result) then
Exit;
Result := TTypeCache.Create;
Ctx := TRttiContext.Create;
RType := Ctx.GetType(ATypeInfo);
L := TList<TPropMap>.Create;
try
for P in RType.GetProperties do
begin
if not P.IsWritable then
Continue;
// Opt-in: alleen Properties met attribuut
Src := FindMapFromAttr(P);
if Src = '' then
Continue;
M.Prop := P;
M.SourceName := Src;
L.Add(M);
end;
Result.Props := L.ToArray;
finally
L.Free;
end;
FCache.Add(ATypeInfo, Result);
finally
TMonitor.Exit(FCacheLock);
end;
end;
class function TRttiMapper.VariantToBoolean(const V: Variant): Boolean;
var
S: string;
begin
if VarIsBool(V) then
Exit(V);
if VarIsNumeric(V) then
Exit(V <> 0);
S := Trim(VarToStr(V)).ToLower;
if (S = '1') or (S = 'true') or (S = 't') or (S = 'y') or (S = 'yes') then
Exit(True);
if (S = '0') or (S = 'false') or (S = 'f') or (S = 'n') or (S = 'no') then
Exit(False);
raise ERttiMappingError.CreateFmt('Boolean-conversie mislukt: "%s"', [VarToStr(V)]);
end;
class function TRttiMapper.VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer;
var
Ord: Integer;
Name: string;
begin
if VarIsNumeric(V) then
begin
Ord := Integer(V);
if (Ord < GetTypeData(AEnumType.Handle)^.MinValue) or
(Ord > GetTypeData(AEnumType.Handle)^.MaxValue) then
raise ERttiMappingError.CreateFmt('Enum-ordinaal buiten bereik: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Enum-naam onbekend: "%s"', [Name]);
Result := Ord;
end;
class procedure TRttiMapper.SetPropertyValue(const AInstance: TObject;
const AProp: TRttiProperty; const AValue: Variant);
var
V: TValue;
T: TRttiType;
Ord: Integer;
begin
T := AProp.PropertyType;
// Conversie bewust selectief: liever duidelijk falen dan stil 'op de een of andere manier'.
case T.TypeKind of
tkUString, tkString, tkLString, tkWString:
V := TValue.From<string>(VarToStr(AValue));
tkInteger, tkInt64:
V := TValue.From<Int64>(VarAsType(AValue, varInt64));
tkFloat:
V := TValue.From<Double>(VarAsType(AValue, varDouble));
tkEnumeration:
begin
if T.Handle = TypeInfo(Boolean) then
V := TValue.From<Boolean>(VariantToBoolean(AValue))
else
begin
Ord := VariantToEnumOrdinal(T, AValue);
V := TValue.FromOrdinal(T.Handle, Ord);
end;
end;
tkSet:
raise ERttiMappingError.CreateFmt('Set-mapping niet geïmplementeerd voor %s', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Class-Property-mapping niet geïmplementeerd voor %s', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind niet ondersteund (%s) voor %s',
[GetEnumName(TypeInfo(TTypeKind), Ord(T.TypeKind)), AProp.Name]);
end;
AProp.SetValue(AInstance, V);
end;
class procedure TRttiMapper.MapToObject(const AReader: IValueReader;
const ATarget: TObject; const AOptions: TRttiMapOptions);
var
Cache: TTypeCache;
M: TPropMap;
V: Variant;
Msg: string;
begin
if (ATarget = nil) or (AReader = nil) then
raise ERttiMappingError.Create('MapToObject: Reader of Target is nil');
Cache := GetOrBuildCache(ATarget.ClassInfo);
for M in Cache.Props do
begin
if not AReader.HasValue(M.SourceName) then
begin
if not (moIgnoreMissing in AOptions) then
raise ERttiMappingError.CreateFmt('Bron ontbreekt: "%s" voor Property %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Zonder Nullable/Optional-mechaniek kan NULL niet zinvol worden gezet.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Gemappt %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Mapping-fout bij %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Waarvoor is dit nuttig
U krijgt een mapping die in code-reviews eenduidig beoordeeld kan worden:
- Elke gemapte Property is visueel gemarkeerd (attribuut).
- De conversie is centraal, daardoor consistent en testbaar.
- Foutmeldingen geven aan, welke Property en welke bron betroffen is.
- Een debug-modus geeft u in geval van twijfel de bewijsketen, zonder dat u breakpoints in het productieproces hoeft te plaatsen.
Randvoorwaarden en typische valkuilen
- NULL-semantiek: Zonder een eigen nullable-concept (bijv.
Nullable<T>of Option-Types) is „NULL zetten“ niet eenduidig. In het snippet wordt NULL standaard overgeslagen. Dat is conservatief en voorkomt stille overschrijvingen. - TRttiContext-levensduur: We bouwen de cache één keer per type en gooien de Context daarna weg. Dat is gebruikelijk. Belangrijk: bouw niet voor elke veldtoewijzing een nieuwe RTTI-Context op.
- Threading: De cache is via Monitor beschermd. In hoogparallelle mappings (bijv. REST-Server) moet u daarnaast nagaan of u de cache al bij opstart „warm“ bouwt (Preload), om Lock-Contention te verminderen.
- PropertyType Kind:
tkClassentkSetzijn opzettelijk niet geïmplementeerd. Voor geneste objecten dient u ofwel recursief te mappen (met een duidelijke policy) of bewust handmatig toe te wijzen. - Locale-valkuilen:
varDoubleviaVarAsTypeis relatief robuust, maar strings zoals „1,23″ vs. „1.23″ blijven een onderwerp. Als uw bronnen strings leveren, is een eigen parser (met gedefinieerde Culture) vaak beter.
Variant voor FireDAC en TDataSet: Reader-Adapter in plaats van Mapper-koppeling
In BDE-Ablosung mit nativer Anbindung- of klassieke VCL/Win32-toepassingen is de bron vaak een TDataSet. In plaats van de mapper aan TField te binden, schrijft u een adapter die de interface IValueReader implementeert. Het voordeel: de mapper blijft onafhankelijk van de data-toegang (belangrijk als u de data-toegang later in services of naar een REST-Server uitbesteedt).
uses Data.DB, System.Variants, RttiMapping;
type
TDataSetValueReader = class(TInterfacedObject, IValueReader)
private
FDS: TDataSet;
public
constructor Create(ADS: TDataSet);
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
constructor TDataSetValueReader.Create(ADS: TDataSet);
begin
inherited Create;
FDS := ADS;
end;
function TDataSetValueReader.HasValue(const AName: string): Boolean;
begin
Result := (FDS <> nil) and (FDS.FindField(AName) <> nil);
end;
function TDataSetValueReader.IsNull(const AName: string): Boolean;
var
F: TField;
begin
F := FDS.FindField(AName);
Result := (F = nil) or F.IsNull;
end;
function TDataSetValueReader.GetValue(const AName: string): Variant;
begin
Result := FDS.FieldByName(AName).Value;
end;
Daardoor ziet een concreet Mapping er als volgt uit:
type
TOrderRow = class
private
FId: Int64;
FCustomerNo: string;
FIsClosed: Boolean;
public
[MapFrom('order_id')]
property Id: Int64 read FId write FId;
[MapFrom('customer_no')]
property CustomerNo: string read FCustomerNo write FCustomerNo;
[MapFrom('is_closed')]
property IsClosed: Boolean read FIsClosed write FIsClosed;
end;
// ...
var
Row: TOrderRow;
Reader: IValueReader;
begin
Row := TOrderRow.Create;
try
Reader := TDataSetValueReader.Create(MyQuery);
TRttiMapper.MapToObject(Reader, Row, [moIgnoreMissing, moDebug, moIgnoreNull]);
finally
Row.Free;
end;
end;
Waar deze aanpak zinvol is – en waar niet
Dit patroon is doorgaans de moeite waard in drie situaties:
- Geleidelijke modernisering: U wilt domeinobjecten invoeren zonder de data‑toegang meteen volledig te herstructureren (typisch bij Delphi modernisering in bestaande applicaties).
- Interfacegrenzen: CSV-/Excel-imports, REST-Payloads of „gemengde“ gegevensbronnen hebben robuuste conversie en duidelijke foutmeldingen nodig.
- Onderhoudbaarheid in het team: Attributen maken mappingregels zichtbaar en reviewbaar, wat in grotere codebases goud waard is.
Er zijn ook duidelijke beperkingen:
- Complexe objectgrafen (child-collections, cyclische referenties) moet u niet „automagisch“ mappen. Hier is expliciete code of een afzonderlijk assembler-/factory‑patroon meestal stabieler.
- High-Throughput-Hotpaths (bijv. massale data‑ETL) profiteren eerder van codegegenereerde mappers of handgeoptimaliseerde mapping, zelfs als RTTI gecached is.
- Nullable/Optional is een apart onderwerp. Als u echt moet onderscheiden tussen „niet aanwezig“, „NULL“ en „Default“, moet u dat in het domeinmodel uitdrukken, niet verbergen in de mapper.
Plaats in architectuur en operatie
Vanuit architectuurperspectief is deze mapper een infrastructuurcomponent op de grens tussen datarepresentatie en domein. Hij vervangt geen zuivere scheiding in lagen, maar kan die wel mogelijk maken: de data‑toegang (FireDAC, SQL, Views) mag praktisch blijven terwijl het domein consistent blijft. In meerlaagse systemen (vaak aangeduid als Layer-3 architectuur: UI, Domain/Services, Infrastructuur) hoort de mapper thuis in de infrastructuur en wordt hij door services gebruikt, niet door UI‑formulieren.
Operationeel belangrijk: schakel moDebug niet permanent in productie‑services, maar selectief. Bij moeilijk reproduceerbare dataproblemen is het zinvol een schakelbaar diagnosepad te hebben (configuratie, feature‑flag). Anders dreigen grote logvolumes en bijwerkingen.
Conclusie: RTTI ja, maar alleen met duidelijke kaders
Delphi RTTI voor mapping zonder magie werkt goed wanneer u RTTI gebruikt als gereedschap voor declaratieve metadata – niet als uitnodiging voor stille heuristieken. Attributen als opt-in, gecentraliseerde conversie, cache per type en duidelijke foutmeldingen brengen het onderwerp van „onduidelijk“ naar „bedrijfsklaar“. De benadering is bewust niet universeel: voor geneste grafen, strikte nulsemantiek of maximale prestaties heeft u aanvullende bouwstenen nodig. Als robuuste brug tussen dataset/legacy-structuren en modernere domeinobjecten is het in veel Delphi-codebasen precies de pragmatische stap die modernisering in de eerste plaats mogelijk maakt.
Als u in een gegroeide Delphi-toepassing vastloopt bij mappinggrenzen, datakwaliteit of stapsgewijze modernisering, kunnen we dat samen zorgvuldig opzetten en in uw architectuur inpassen: Neem contact op.
In het vakgebied spelen ook Delphi Rtti Mapping en Attribute Mapping Delphi een belangrijke rol, wanneer integraties, gegevensstromen en verdere ontwikkeling naadloos moeten samenwerken.