Net-Base Magasin

08.05.2026

Delphi RTTI til Mapping uden magi: attributbaseret, fejlsøgbar og legacy-kompatibel

Et pragmatisk Mapping-mønster med Delphi RTTI: attributter i stedet for konventioner, kontrollerede konverteringer, klare fejltekster og en debug-tilstand, der i drift rent faktisk hjælper. Med kildeudsnit til Dataset- eller Record-til-objekt-mapping uden skjult magi.

08.05.2026

Den, som driver voksede forretningssoftware i Delphi, kender spændingsfeltet: På den ene side ønsker man strukturerede domæneobjekter og klare lag, på den anden side findes der Datasets, Variants, CSV-importer, interfacerespons-payloads eller en REST-API, der „på en eller anden måde“ skal mappes til objekter. Netop her ender man hurtigt hos Delphi RTTI für Mapping ohne Magie: altså mapping via reflection (RTTI = Run-Time Type Information, typeoplysninger ved køretid), men sådan at det forbliver efterprøvbart, let at debugge og ikke hemmeligt afhænger af konventioner eller navnelege.

Kernen: „Magi“ opstår som regel ikke af RTTI i sig selv, men af implicitte regler. Når mapping-regler derimod står eksplicit i attributter, konverteringer er centraliserede, og fejl angiver en klar årsag, bliver RTTI et værktøj i stedet for en overraskelse.

Hvorfor RTTI-mapping i Delphi ofte svigter

RTTI-baseret mapping fejler i reelle systemer sjældent på idéen, men på rammebetingelser:

  • Legacy-datatyper: Null/Empty/0 er ikke klart adskilt, felttyper skifter, strenge indeholder „N/A“.
  • Snigende konventioner: „Feltet hedder som Property“ fungerer indtil det første alias, join eller refaktorerede property-navn.
  • Svært at debugge: Hvis en mapper „bare ikke sætter noget“, mangler årsagen senere. I drift er det skadeligt.
  • Performance-myter: RTTI stemples pauschalt som „langsom“, selvom manglende caching ofte er problemet.

En holdbar tilgang bør derfor (1) have eksplicitte mapping-metadatas, (2) behandle konvertering og null-semantik klart, (3) levere fejl- og debug-udskrifter og (4) cache RTTI-informationer.

Delphi RTTI für Mapping ohne Magie: Designprincipper

Det følgende mønster er bevidst „kedeligt“ i bedste forstand: Regler er synlige, bivirkninger er begrænsede, og det kan indføres trinvis i eksisterende moduler.

  • Attributter frem for navnekonvention: En property får et attribut, der angiver kildesøjlen.
  • Opt-in: Kun markerede properties sættes. Ingen overraskelser fra „alle publicerede properties“.
  • Konvertering ét sted: Variant/String/Integer/Boolean/Enum/Nullable mappes centralt.
  • Debug-Mode: Valgfrit logges, hvilke felter blev sat/oversprunget – med forklaring.
  • RTTI-Caching: De dyreste dele (propertyliste, attributevaluering) forberedes pr. type.

Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug

Udklippet mapper en række (f.eks. fra BDE-Ablosung mit nativer Anbindung via TDataSet) til et objekt. I stedet for at binde mapperen fast til TField bruger vi en lille reader-grænseflade. Det er i praksis værdifuldt, fordi samme logik senere kan anvendes for JSON, INI, CSV eller API-responser.

unit RttiMapping;

interface

uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;

type
// Eksplicit mapping: Property <- kildenavn
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;

// Lille abstraktion: returnere værdi + skelne mellem eksistens/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: Kun Properties med attribut
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-konvertering mislykkedes: „%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-ordinal uden for gyldigt område: %d‘, [Ord]);
Exit(Ord);
end;

Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Enum-navn ukendt: „%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;

// Konvertering bevidst selektiv: hellere fejle tydeligt end stille „på en eller anden måde“.
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 ikke implementeret for %s‘, [AProp.Name]);

tkClass:
raise ERttiMappingError.CreateFmt(‚Class-property mapping ikke implementeret for %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind ikke understøttet (%s) for %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 eller Target er 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(‚Kilde mangler: „%s“ for Property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;

if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Uden nullable/optional-mekanik kan NULL ikke sættes meningsfuldt.
Continue;
end;

V := AReader.GetValue(M.SourceName);

try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Mapped %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Mapping-fejl ved %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;

end.

Hvad det er godt til

Du får et mapping, som kan vurderes klart i code reviews:

  • Hver mappet Property er visuelt markeret (attribut).
  • Konverteringen er central, dermed konsistent og testbar.
  • Fejltekster angiver, hvilken Property og hvilken kilde der er berørt.
  • En debug-tilstand giver dig i tvivlstilfælde beviskæden, uden at du behøver breakpoints i produktionsprocessen.

Rammebetingelser og typiske faldgruber

  • NULL-semantik: Uden et eget Nullable-koncept (f.eks. Nullable<T> eller option-typer) er „sætte NULL“ ikke entydigt. I eksemplet springes NULL som standard over. Det er konservativt og forhindrer stille overskrivninger.
  • TRttiContext-levetid: Vi bygger cachen én gang pr. type og kasserer Context bagefter. Det er almindeligt. Vigtigt: Byg ikke en ny RTTI-Context pr. felttildeling.
  • Threading: Cachen er beskyttet via Monitor. I højt parallelle mappings (f.eks. REST-Server) bør du desuden overveje at bygge cachen ‚varm‘ ved opstart (Preload) for at reducere lock-Contention.
  • PropertyType Kind: tkClass og tkSet er bevidst ikke implementeret. For indlejrede objekter bør du enten mappe rekursivt (med en klar policy) eller tildele manuelt.
  • Locale-fald: varDouble via VarAsType er relativt robust, men strenge som „1,23“ vs. „1.23“ er stadig et problem. Hvis dine kilder leverer strings, er en egen parser (med defineret Culture) ofte bedre.

Variant for FireDAC og TDataSet: Reader-Adapter i stedet for Mapper-kobling

I BDE-Ablosung mit nativer Anbindung- eller klassiske VCL/Win32-applikationer er kilden ofte et TDataSet. I stedet for at binde mapperen til TField, skriver du en adapter, som implementerer interfacet IValueReader. Fordelen: Mapperen forbliver uafhængig af dataadgangen (vigtigt, hvis du senere flytter dataadgangen til services eller en REST-Server).

Delphi
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;

Et konkret mapping ser sådan ud:

Delphi
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;

Hvor tilgangen er relevant – og hvor ikke

Dette mønster er typisk relevant i tre situationer:

  1. Trinvis modernisering: Ønsker man at indføre domæneobjekter uden at omlægge dataadgangen fuldstændigt med det samme (klassisk ved Delphi Modernisering i eksisterende applikationer).
  2. Grænseflader: CSV-/Excel-importer, REST-Payloads eller „blandede“ datakilder kræver robust konvertering og klare fejlmeddelelser.
  3. Vedligeholdelse i teamet: Attributter gør mapping-regler synlige og gennemgåelige, hvilket er særdeles værdifuldt i større kodebaser.

Der er også klare anvendelsesgrænser:

  • Komplekse objektgrafer (Child-Collections, cykliske referencer) bør man ikke „automagisk“ mappe. Her er eksplicit kode eller et separat Assembler-/Factory-mønster som regel mere stabilt.
  • High-Throughput-Hotpaths (f.eks. Massendaten-ETL) profiterer typisk af codegenererede mapper eller håndoptimeret mapping, selv når RTTI er cachet.
  • Nullable/Optional er et selvstændigt emne. Hvis man virkelig skal skelne mellem „ikke til stede“, „NULL“ og „Default“, bør man udtrykke det i domænemodellen, ikke skjule det i mapperen.

Placering i arkitektur og drift

Fra et arkitekturmæssigt perspektiv er denne mapper en infrastrukturkomponent på grænsen mellem datarepræsentation og domæne. Den erstatter ikke en ren lagdeling, men kan muliggøre den: Dataadgangen (FireDAC, SQL, Views) må fortsat være pragmatisk, mens domænet forbliver konsistent. I flerlagsystemer (ofte benævnt Layer-3 Arkitektur: UI, Domain/Services, Infrastruktur) hører mapperen til i infrastrukturen og bruges af services, ikke af UI-formularer.

Operationelt vigtigt: Aktivér ikke moDebug permanent i produktionsservices, men kun målrettet. For svært reproducerbare dataproblemer er det fornuftigt at have en skiftbar diagnosevej (konfiguration, Feature-Flag). Ellers risikerer man log-volumen og sideeffekter.

Konklusion: RTTI ja, men kun med klare retningslinjer

Delphi RTTI til mapping uden magi fungerer godt, når du bruger RTTI som et værktøj til deklarative metadata – ikke som en invitation til skjulte heuristikker. Attributter som opt-in, centraliseret konvertering, cache pr. type og forståelige fejlmeddelelser løfter emnet fra ‚uforståeligt‘ til ‚driftsklart‘. Tilgangen er bevidst ikke universel: For indlejrede grafer, streng nul-semantik eller maksimal ydeevne behøver du yderligere byggesten. Som en robust bro mellem dataset-/legacy-strukturer og mere moderne domæneobjekter er den i mange Delphi-kodebaser netop det pragmatiske skridt, der gør modernisering mulig.

Hvis du i en etableret Delphi-applikation sidder fast på mapping-grænser, datakvalitet eller trinvis modernisering, kan vi sætte det op sammen og indpasse det i din arkitektur: Kontakt os.

I faglige sammenhænge spiller også Delphi Rtti Mapping og Attribute Mapping Delphi en vigtig rolle, når integrationer, dataflows og videreudvikling skal spille ordentligt sammen.

Drøft projekt eller moderniseringsforløb med Net-Base.

Del indlæg

Del dette indlæg direkte

LinkedIn, X, XING, Facebook, WhatsApp og e-mail er straks tilgængelige. Til Instagram forbereder vi link og kort tekst med det samme.

E-mail

Instagram åbner i en ny fane. Linket og kortteksten kopieres på forhånd til udklipsholderen.