Net-Base Magasin

08.05.2026

Delphi RTTI for Mapping uten magi: attributtbasert, feilsøkbar og legacy-kompatibel

Et pragmatisk mapping-mønster med Delphi RTTI: attributter i stedet for konvensjoner, kontrollerte konverteringer, klare feilmeldinger og en debug-modus som virkelig hjelper i drift. Med kodeeksempler for datasett- eller post-til-objekt-mapping uten skjult magi.

08.05.2026

Den som driver modnet bedriftsprogramvare i Delphi, kjenner spenningsfeltet: På den ene siden ønsker man strukturerte domenetobjekter og klare lag, på den andre siden finnes det Datasets, Variants, CSV-importer, Schnittstellenpayloads eller en REST-API som „på en eller annen måte“ må mappes til objekter. Nettopp her ender man raskt opp med Delphi RTTI for mapping uten magi: altså mapping via refleksjon (RTTI = Run-Time Type Information, typinformasjon ved kjøretid), men slik at det er etterprøvbart, godt feilsøkbart og ikke hemmelig bundet til konvensjoner eller navnelek.

Kjernen: „magi“ oppstår vanligvis ikke av RTTI i seg selv, men av implisitte regler. Når mapping-reglene derimot står eksplisitt i attributter, konverteringer er sentralisert og feil navngir en klar årsak, blir RTTI et verktøy i stedet for en overraskelse.

Hvorfor RTTI-mapping i Delphi ofte feiler

RTTI-basert mapping mislykkes i reelle systemer sjelden på idéen, men på randbetingelser:

  • Eldre dataformer: Null/Empty/0 er ikke klart separert, felttyper endrer seg, strenger inneholder „N/A“.
  • Smygende konvensjoner: „Felt heter som Property“ fungerer til det første aliaset, joinen eller det refaktorerte property-navnet.
  • Vanskelig å feilsøke: Når en mapper „bare ikke setter noe“, mangler årsaken senere. I drift er det gift.
  • Ytelsesmyter: RTTI stemplers ofte som „langsomt“, selv om manglende caching som regel er problemet.

En bærekraftig tilnærming bør derfor (1) ha eksplisitte mapping-metadata, (2) håndtere konvertering og null-semantikk tydelig, (3) gi feil- og debug-utdata, og (4) cache RTTI-informasjon.

Delphi RTTI for mapping uten magi: designprinsipper

Mønsteret nedenfor er bevisst „kjedelig“ i beste forstand: reglene er synlige, bivirkninger begrenset, og det kan rulles inn trinnvis i eksisterende moduler.

  • Attributter i stedet for navnekonvensjon: Property får et attributt som navngir kildesøylen.
  • Opt-in: Bare merkede Properties settes. Ingen overraskelser fra „alle publiserte Properties“.
  • Konvertering på ett sted: Variant/String/Integer/Boolean/Enum/Nullable mappes sentralt.
  • Debug-modus: Valgfritt logges hvilke felter som ble satt/oversprunget 6 med grunn.
  • RTTI-caching: De dyreste delene (propertyliste, attributtevaluering) forberedes per type.

Kildeutdrag: Attributt-mapping med RTTI, caching og debug

Snutten mapper en rad (f.eks. fra BDE-erstatning med native tilkobling via TDataSet) til et objekt. I stedet for å knytte mapperen fast til TField bruker vi et lite reader-grensesnitt. Det er i praksis verdifullt, fordi samme logikk senere kan brukes for JSON, INI, CSV eller API-responser.

unit RttiMapping;

interface

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

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

// Enkel abstraksjon: levere verdi + skille 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: Bare properties med attributt
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 mislyktes: „%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 utenfor område: %d‘, [Ord]);
Exit(Ord);
end;

Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Enum-navn ukjent: „%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 bevisst selektiv: heller feile tydelig enn å „på en måte“ feile stille.
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 implementert for %s‘, [AProp.Name]);

tkClass:
raise ERttiMappingError.CreateFmt(‚Class-property-mapping ikke implementert for %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind ikke stø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;
// Uten nullable/optional-mekanikk kan man ikke sette NULL meningsfullt.
Continue;
end;

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

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

end.

Hva dette er nyttig for

Du får et mapping som i kodegjennomganger kan vurderes presist:

  • Hver mappet Property er visuelt merket (Attribut).
  • Konverteringen er sentralisert, dermed konsistent og testbar.
  • Feilmeldinger sier, hvilken Property og hvilken kilde som er berørt.
  • En feilsøkingsmodus gir deg ved behov beviskjeden, uten at du trenger breakpoints i produksjonsprosessen.

Forutsetninger og typiske fallgruver

  • NULL-semantikk: Uten eget nullable-konsept (f.eks. Nullable<T> eller Option-Types) er det ikke entydig å sette NULL. I snippetet hoppes NULL over som standard. Dette er konservativt og forhindrer stille overskrivinger.
  • TRttiContext-levetid: Vi bygger cachen én gang per type og kaster Context etterpå. Det er vanlig. Viktig: Ikke bygg en ny RTTI-Context per felttildeling.
  • Threading: Cachen er via Monitor beskyttet. I høyt parallelliserte mappings (f.eks. REST-Server) bør du i tillegg vurdere om du skal bygge cachen ‚varm‘ ved oppstart (Preload) for å redusere lock-contention.
  • PropertyType Kind: tkClass og tkSet er bevisst ikke implementert. For nestede objekter bør du enten mape rekursivt (med klar Policy) eller bevisst tildele manuelt.
  • Locale-feller: varDouble via VarAsType er relativt robust, men strenger som „1,23“ vs. „1.23“ er fortsatt et tema. Hvis kildene dine leverer strenger, er en egen parser (med definert Culture) ofte bedre.

Variant for FireDAC og TDataSet: Reader-Adapter i stedet for mapper-kopling

I BDE-Ablosung mit nativer Anbindung- eller klassiske VCL/Win32-applikasjoner er kilden ofte et TDataSet. I stedet for å binde mapperen til TField, skriver du en adapter som implementerer grensesnittet IValueReader. Fordelen: Mapperen forblir uavhengig av dataadgangen (viktig hvis du senere flytter dataadgangen til tjenester 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;

Slik ser et konkret mapping ut:

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 denne tilnærmingen lønner seg – og hvor ikke

Dette mønsteret lønner seg typisk i tre situasjoner:

  1. Trinnvis modernisering: Dere ønsker å innføre domeneobjekter uten å bygge om dataadgangen fullstendig med én gang (vanlig ved Delphi modernisering i eksisterende applikasjoner).
  2. Grenseflater: CSV-/Excel-importer, REST-payloads eller „blandede“ datakilder trenger robust konvertering og gode feilmeldinger.
  3. Vedlikehold i teamet: Attributter gjør mapping-regler synlige og gjennomgåelige, noe som er svært verdifullt i større kodebaser.

Det finnes også klare begrensninger for bruken:

  • Komplekse objektgrafer (Child-Collections, sykliske referanser) bør man ikke mappe „automagisk“. Her er eksplisitt kode eller et separat Assembler/Factory-mønster som regel mer stabilt.
  • High-Throughput-Hotpaths (f.eks. Massendaten-ETL) drar ofte mer nytte av kodegenererte mapper eller håndoptimalisert mapping, selv når RTTI er cachet.
  • Nullable/Optional er et eget tema. Hvis man virkelig må skille mellom „ikke til stede“, „NULL“ og „Default“, bør man uttrykke det i domenemodellen, ikke skjule det i mapperen.

Plassering i arkitektur og drift

Fra et arkitektursynspunkt er denne mapperen en infrastrukturkomponent ved grensen mellom datarepresentasjon og domene. Den erstatter ikke ren lagdeling, men kan gjøre den mulig: Dataadgangen (FireDAC, SQL, Views) kan fortsatt være pragmatisk, mens domenet forblir konsistent. I flerlagsystemer (ofte kalt Layer-3 arkitektur: UI, Domene/Tjenester, Infrastruktur) hører mapperen hjemme i infrastrukturen og brukes av tjenester, ikke av UI-skjemaer.

Operasjonelt viktig: Aktiver ikke moDebug permanent i produksjonstjenester, men målrettet. For vanskelig reproducerbare dataproblemer er det fornuftig å ha en konfigurerbar diagnosevei (konfigurasjon, feature-flag). Ellers risikerer man stort loggvolum og bivirkninger.

Konklusjon: RTTI ja, men bare med klare føringer

Delphi RTTI for mapping uten magi fungerer godt når du bruker RTTI som et verktøy for deklarative metadata – ikke som en invitasjon til tause heuristikker. Attributter som opt-in, sentralisert konvertering, cache per type og forståelige feilmeldinger løfter temaet fra „uklar“ til „driftsklar“. Tilnærmingen er bevisst ikke universell: For innfløkte grafer, streng null-semantikk eller maksimal ytelse trenger du ytterligere komponenter. Som en robust bro mellom dataset-/legacy-strukturer og mer moderne domeneobjekter er den i mange Delphi-kodebaser nettopp det pragmatiske steget som gjør modernisering mulig.

Hvis du i en etablert Delphi-applikasjon sitter fast på mapping-kanter, datakvalitet eller trinnvis modernisering, kan vi sette dette opp ryddig sammen og tilpasse det til arkitekturen din: Kontakt oss.

I faglige sammenhenger spiller også Delphi Rtti Mapping og Attribute Mapping Delphi en viktig rolle når integrasjoner, dataflyter og videreutvikling må fungere sømløst sammen.

Drøfte prosjekt eller moderniseringsprosjekt med Net-Base.

Del innlegg

Del dette innlegget direkte

LinkedIn, X, XING, Facebook, WhatsApp og e‑post er umiddelbart tilgjengelig. For Instagram forbereder vi lenke og kort tekst umiddelbart.

E-post

Instagram åpnes i en ny fane. Lenken og kortteksten kopieres først til utklippstavlen.