Net-Base Magazín

08.05.2026

Delphi RTTI pro mapování bez magie: založené na atributech, laditelné a kompatibilní se starším kódem

Pragmatický vzor mapování s Delphi RTTI: atributy místo konvencí, kontrolované konverze, jasné chybové zprávy a režim ladění, který v provozu skutečně pomáhá. S ukázkami zdrojového kódu pro mapování Dataset nebo Record na objekt bez skryté magie.

08.05.2026

Kdo provozuje vzniklý businessový software v Delphi, zná toto napětí: na jedné straně chcete strukturované doménové objekty a jasné vrstvy, na druhé straně existují Datasets, Variants, CSV importy, payloady rozhraní nebo REST-API, které je třeba „nějak“ namapovat na objekty. Právě zde se rychle dostanete k Delphi RTTI für Mapping ohne Magie: tedy mapování pomocí reflection (RTTI = Run-Time Type Information, typové informace za běhu), ale tak, aby to zůstalo srozumitelné, dobře laditelné a aby to nebylo skrytě založeno na konvencích nebo hrách s názvy.

Podstata: „magie“ obvykle nevzniká samotným RTTI, ale implicitními pravidly. Pokud jsou mapovací pravidla explicitně v atributech, konverze jsou centralizované a chyby jasně pojmenovávají příčinu, stává se RTTI nástrojem místo překvapení.

Proč RTTI-mapping v Delphi často selhává

RTTI-založené mapování v reálných systémech málokdy ztroskotá na myšlence, častěji na okrajových podmínkách:

  • Legacy-Datenformen: Null/Empty/0 nejsou od sebe čistě odděleny, typy polí se mění, řetězce obsahují „N/A“.
  • Schleichende Konventionen: „Pole se jmenuje jako Property“ funguje do prvního aliasu, joinu nebo refaktorizovaného názvu property.
  • Schwer zu debuggen: Když mapper „prostě nic nenastaví“, později chybí příčina. V provozu je to jedovaté.
  • Performance-Mythen: RTTI je paušálně označováno za „pomalé“, i když je problém většinou v chybějícím cachování.

Udržitelný přístup by proto měl (1) mít explicitní mapovací metadata, (2) jasně řešit konverzi a null-semantiku, (3) dodávat chyby a ladicí výstupy a (4) cacheovat RTTI-informace.

Delphi RTTI für Mapping ohne Magie: Designprinzipien

Následující vzor je záměrně „nudný“ v tom nejlepším slova smyslu: pravidla jsou viditelná, vedlejší efekty omezené a lze jej postupně zavádět do stávajících modulů.

  • Attribute statt Namenskonvention: Property dostane atribut, který pojmenuje zdrojový sloupec.
  • Opt-in: Nastavují se pouze označené Properties. Žádná překvapení způsobená „všemi publikovanými properties“.
  • Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable jsou mapovány centrálně.
  • Debug-Mode: Volitelně se protokoluje, která pole byla nastavena/přeskočena – s uvedením důvodu.
  • RTTI-Caching: Nejdražší části (seznam properties, vyhodnocení atributů) se připraví pro každý typ.

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

Snippet namapuje jeden řádek (např. z BDE-Ablosung mit nativer Anbindung via TDataSet) na objekt. Místo pevného vázání mapperu na TField používáme malé reader‑rozhraní. V praxi je to cenné, protože později můžete stejnou logiku použít i pro JSON, INI, CSV nebo API‑responses.

unit RttiMapping;

interface

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

type
// Explicitní mapování: Property <- jméno zdroje
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;

// Malá abstrakce: poskytne hodnotu a rozliší existenci/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: pouze Properties s atributem
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(‚Konverze na boolean selhala: „%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 mimo rozsah: %d‘, [Ord]);
Exit(Ord);
end;

Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Neznámý název výčtu: „%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;

// Konverze záměrně selektivní: raději jasně selhat než tiše „nějak“.
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(‚Mapování Set není implementováno pro %s‘, [AProp.Name]);

tkClass:
raise ERttiMappingError.CreateFmt(‚Mapování Class-Property není implementováno pro %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind není podporován (%s) pro %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 nebo Target je 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(‚Zdroj chybí: „%s“ pro Property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;

if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Bez mechaniky Nullable/Optional nelze NULL smysluplně přiřadit.
Continue;
end;

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

try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Namapováno %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Chyba při mapování %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;

end.

K čemu to slouží

Získáte mapování, které lze v revizích kódu čistě posoudit:

  • Každá namapovaná vlastnost (Property) je vizuálně označena (Attribut).
  • Konverze je centralizovaná, díky tomu konzistentní a testovatelná.
  • Chybová hlášení říkají, která Property a který zdroj jsou dotčeny.
  • Debugovací režim vám v případě potřeby poskytne sled důkazů, aniž byste museli používat Breakpoints v produkčním procesu.

Okrajové podmínky a typické nástrahy

  • NULL-Semantik: Bez vlastního Nullable-konceptu (např. Nullable<T> nebo Option-Types) není „NULL setzen“ jednoznačné. V ukázce je NULL standardně přeskočeno. To je konzervativní a zabraňuje tichému přepisování.
  • TRttiContext-Lebensdauer: Vytváříme cache jednou na typ a Context poté zahodíme. To je obvyklé. Důležité je: nevytvářet pro každé přiřazení pole nový RTTI-Context.
  • Threading: Cache je chráněna pomocí Monitoru. U vysoce paralelních mapování (např. REST-Server) byste navíc měli zvážit, zda cache už při startu „ohřát“ (Preload), abyste snížili soutěž o zámky (lock contention).
  • PropertyType Kind: tkClass a tkSet jsou záměrně neimplementovány. Pro vnořené objekty byste buď měli mapovat rekurzivně (s jasnou Policy), nebo je vědomě přiřadit ručně.
  • Locale-Fallen: varDouble přes VarAsType je relativně robustní, ale řetězce jako „1,23“ vs. „1.23“ jsou přesto problém. Pokud vaše zdroje dodávají řetězce, je často lepší vlastní parser (s definovanou Culture).

Varianta pro FireDAC a TDataSet: Reader-Adapter místo provázání s Mapperem

V BDE-Ablosung mit nativer Anbindung- nebo v klasických VCL/Win32 aplikacích je zdrojem často TDataSet. Místo aby byl Mapper vázán na TField, napište adaptér, který implementuje rozhraní IValueReader. Výhoda: Mapper zůstává nezávislý na přístupu k datům (důležité, pokud chcete přístup k datům později přesunout do služeb nebo na 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;

Konkrétní mapování pak vypadá takto:

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;

Kde se tento přístup vyplatí — a kde ne

Tento vzor se typicky vyplatí v těchto třech situacích:

  1. Postupná modernizace: Chcete zavést doménové objekty, aniž byste okamžitě kompletně předělali přístup k datům (typické při Delphi modernizace ve stávajících aplikacích).
  2. Rozhraní na krajích: CSV-/Excel-importy, REST-payloady nebo „smíšené“ zdroje dat vyžadují robustní konverzi a srozumitelné chybové hlášení.
  3. Udržovatelnost v týmu: Atributy zpřehledňují mapovací pravidla a činí je přezkoumatelnými, což ve větších codebasech má velkou hodnotu.

Existují také jasné limity použití:

  • Komplexní grafy objektů (Child-Collections, cyklické reference) byste neměli „automagicky“ mapovat. Zde je obvykle stabilnější explicitní kód nebo samostatný Assembler/Factory-vzor.
  • High-Throughput-Hotpaths (např. ETL hromadných dat) mají spíše prospěch z codegenerovaných mapperů nebo ručně optimalizovaného mapování, i když je RTTI cachováno.
  • Nullable/Optional je samostatné téma. Pokud musíte skutečně rozlišit mezi „neexistuje“, „NULL“ a „výchozí hodnotou“, měli byste to vyjádřit v doménovém modelu, ne skrývat v mapperu.

Zařazení v architektuře a provozu

Z architektonického hlediska je tento mapper infrastrukturní komponentou na rozhraní mezi reprezentací dat a doménou. Nezastupuje čisté vrstvení, může jej však umožnit: přístup k datům (FireDAC, SQL, Views) může zůstat pragmatický, zatímco doména zůstane konzistentní. V vícevrstvých systémech (často označovaných jako Layer-3 architektura: UI, doména/služby, infrastruktura) patří mapper do infrastruktury a používají ho služby, ne UI formuláře.

Pro provoz důležité: Neaktivujte trvale moDebug v produkčních službách, ale cíleně. Pro obtížně reprodukovatelné problémy s daty je užitečné mít přepínatelnou diagnostickou cestu (konfigurace, feature-flag). Jinak hrozí objem logů a nežádoucí vedlejší efekty.

Závěr: RTTI ano, ale pouze s jasnými pravidly

Delphi RTTI pro mapování bez magie funguje dobře, když používáte RTTI jako nástroj pro deklarativní metadata – nikoli jako pozvánku ke skrytým heuristikám. Atributy jako opt-in, centralizovaná konverze, cache na typ a srozumitelné chybové texty přemění téma z „neprůhledné“ na „provozuschopné“. Přístup není záměrně univerzální: pro vnořené grafy, striktní null-semantiku nebo maximální výkon potřebujete další stavební kameny. Jako robustní most mezi strukturami datasetů/legacy a modernějšími doménovými objekty je to v mnoha Delphi-kódbázích právě ten pragmatický krok, který modernizaci vůbec umožňuje.

Pokud v zavedené Delphi-aplikaci právě narážíte na hranice mapování, kvalitu dat nebo postupnou modernizaci, můžeme to společně čistě nastavit a přizpůsobit vaší architektuře: Kontaktujte nás.

V odborném kontextu mají také Delphi Rtti Mapping a Attribute Mapping Delphi důležitou roli, pokud musí integrace, datové toky a další vývoj hladce spolupracovat.

Projekt nebo modernizační záměr probrat s Net-Base.

Sdílet příspěvek

Sdílet tento příspěvek přímo

LinkedIn, X, XING, Facebook, WhatsApp a e-mail jsou ihned k dispozici. Pro Instagram připravíme odkaz a stručný text.

E-mail

Instagram se otevře v nové záložce. Odkaz a krátký text budou předtím zkopírovány do schránky.