Net-Base Magazín

10.05.2026

Mapování datasetu na objekt pro neobvyklé zastaralé struktury: stabilní, snadno laditelné, bez ORM magie

Když jsou legacy datové sady historicky vzniklé, standardní mapery často narážejí na aliasové sloupce, smíšené datové typy a proměnlivé struktury JOIN. Tento zdrojový úryvek ukazuje robustní, laditelné mapování datasetu na objekt v Delphi: s plánem mapování, konvertory, semantikou null...

10.05.2026

V rozrůstajících se Delphi-systémech je mapování datasetu na objekt zřídka ten čistý případ „jedno pole = jedna vlastnost“. V individuálním podnikatelském softwaru se místo toho setkáte s aliasovými sloupci z view, výsledky joinů s duplicitními názvy polí, „prázdnými“ hodnotami jako 0 nebo ' ', typovanými poli, která dnes vrací VARCHAR a zítra INTEGER, a sloupci, které podle vyhledávacího dialogu prostě nejsou přítomny. Právě tam mnoho mapperů selhává: buď jsou „magické“ (a tím obtížně laditelné), nebo jsou tak striktní, že i volitelné pole zastaví provoz.

Tento úryvek zdrojového kódu ukazuje pragmatický mapper pro Delphi, který záměrně není ORM, ale čistě adresuje nejdůležitější legacy okrajové případy: jednoznačné rozlišení polí, kontrolovanou konverzi, null-semantiku, volitelná pole a srozumitelné chybové hlášky. Hodí se pro Data-Access-Layer (DAL, tedy vrstvu, která zapouzdřuje přístup k datům) nebo repository-patterny – a lze jej dobře kombinovat s BDE-náhradou s nativním napojením (Delphis knihovna přístupu k datům pro mnoho DB).

Proč standardní mapování u starých struktur selhává

Několik typických příčin z provozu, které se při „čistém“ novém návrhu málokdy vyskytují:

  • Víceznačné názvy polí: Join vrací ID z více tabulek; v datasetu se pak jmenuje ID, ID_1 nebo je přejmenováno SQL aliasem.
  • Sémantické nuly: 0 znamená „neznámé“, '1899-12-30' je „žádné datum“, ' ' je „nevyplněno“.
  • Proměnlivé typy: View nekastuje; ovladač dodá ftWideString místo ftInteger. Konverze Variant se stává zdrojem chyb.
  • Volitelné sloupce: Vyhledávací dialog podle filtru používá jiné SELECT-seznamy. Kód ale očekává pole „vždy“.
  • Laditelnost: Když se mapování ztratí v RTTI, je hledání chyb v datech zákazníka obtížné (které pole, která hodnota, který typ?).

Přístup: plán mapování místo konvence, s řízenou konverzí

Jádrem je plán mapování: seznam pravidel „vlastnost X pochází z pole A nebo B, je volitelná/povinná, používá konvertor Y“. Tím zůstává mapování deklarativní, ale není „neviditelné“ jako u mnoha ORM mechanismů. Navíc může mapper pro každé pole vyhodit výstižnou výjimku včetně názvu pole, datového typu a surové hodnoty.

Důležité: Záměrně mapujeme z TDataSet, ne z konkrétní třídy BDE-Ablosung mit nativer Anbindung. Tím zůstane kompatibilní s TFDQuery, TClientDataSet nebo i cizími komponentami.

Ukázka zdrojového kódu: laditelné mapování datasetu na objekt pro legacy sloupce

Kód implementuje:

  • Řešení polí přes seznam priorit (aliasy/záložní varianty)
  • Ošetření povinných/volitelných polí
  • Null-semantiku přes konvertory (např. 0 => Null)
  • Stabilní chybová hlášení s kontextem
  • Debug-hook, který umožní reprodukovat problémy s mapováním při testování nebo v případě podpory
Delphi
unit Legacy.DatasetMapper;

interface

uses
  System.SysUtils, System.Variants, System.Generics.Collections, Data.DB;

type
  EDataMappingError = class(Exception)
  private
    FFieldNames: string;
    FTarget: string;
    FDataType: string;
    FRawValue: string;
  public
    constructor Create(const ATarget, AFieldNames, ADataType, ARawValue, AMsg: string);
    property Target: string read FTarget;
    property FieldNames: string read FFieldNames;
    property DataType: string read FDataType;
    property RawValue: string read FRawValue;
  end;

  TMapRequired = (mrOptional, mrRequired);

  TMapDebugEvent = reference to procedure(
    const TargetMember: string;
    const SourceField: string;
    const SourceType: TFieldType;
    const SourceValue: Variant);

  // Konvertér přijímá Variant a vrací Variant (např. Null, Integer, String, TDateTime jako Double)
  TFieldConverter = reference to function(const V: Variant): Variant;

  TFieldSpec = record
    TargetMember: string;
    SourceCandidates: TArray<string>;
    Required: TMapRequired;
    Converter: TFieldConverter;
    class function Create(const ATarget: string; const ACandidates: array of string;
      ARequired: TMapRequired; const AConverter: TFieldConverter): TFieldSpec; static;
  end;

  TLegacyDatasetMapper = class
  private
    FOnDebug: TMapDebugEvent;
    function FindFieldByCandidates(DS: TDataSet; const Candidates: TArray<string>): TField;
    function FieldTypeToString(FT: TFieldType): string;
    function VariantToDiag(const V: Variant): string;
  public
    property OnDebug: TMapDebugEvent read FOnDebug write FOnDebug;

    // MapOne: volá setter pro každou specifikaci. Bez RTTI: explicitní přiřazení je lépe laditelné.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Pomocné konvertory
function C_TrimToNull: TFieldConverter;
function C_ZeroToNull: TFieldConverter;
function C_StrictInt: TFieldConverter;
function C_DateFromStringOrNull: TFieldConverter;

implementation

{ EDataMappingError }

constructor EDataMappingError.Create(const ATarget, AFieldNames, ADataType, ARawValue, AMsg: string);
begin
  inherited Create(AMsg);
  FTarget := ATarget;
  FFieldNames := AFieldNames;
  FDataType := ADataType;
  FRawValue := ARawValue;
end;

{ TFieldSpec }

class function TFieldSpec.Create(const ATarget: string; const ACandidates: array of string;
  ARequired: TMapRequired; const AConverter: TFieldConverter): TFieldSpec;
var
  I: Integer;
begin
  Result.TargetMember := ATarget;
  SetLength(Result.SourceCandidates, Length(ACandidates));
  for I := 0 to High(ACandidates) do
    Result.SourceCandidates[I] := ACandidates[I];
  Result.Required := ARequired;
  Result.Converter := AConverter;
end;

{ TLegacyDatasetMapper }

function TLegacyDatasetMapper.FieldTypeToString(FT: TFieldType): string;
begin
  Result := GetEnumName(TypeInfo(TFieldType), Ord(FT));
end;

function TLegacyDatasetMapper.VariantToDiag(const V: Variant): string;
begin
  if VarIsNull(V) then Exit('NULL');
  if VarIsEmpty(V) then Exit('EMPTY');
  try
    Result := VarToStr(V);
  except
    Result := '<unprintable variant>';
  end;
end;

function TLegacyDatasetMapper.FindFieldByCandidates(DS: TDataSet; const Candidates: TArray<string>): TField;
var
  Name: string;
begin
  Result := nil;
  for Name in Candidates do
  begin
    // FindField místo FieldByName: možné volitelně, bez výjimky
    Result := DS.FindField(Name);
    if Result <> nil then
      Exit;
  end;
end;

procedure TLegacyDatasetMapper.MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
  const Assign: TProc<string, Variant>);
var
  Spec: TFieldSpec;
  F: TField;
  Raw, Val: Variant;
  CandidatesJoined: string;
  I: Integer;
  FT: string;
begin
  if (DS = nil) then
    raise EArgumentNilException.Create('DS');
  if not DS.Active then
    raise EInvalidOperation.Create('Dataset není aktivní.');

  for Spec in Specs do
  begin
    F := FindFieldByCandidates(DS, Spec.SourceCandidates);

    if (F = nil) then
    begin
      if Spec.Required = mrRequired then
      begin
        CandidatesJoined := '';
        for I := 0 to High(Spec.SourceCandidates) do
        begin
          if I > 0 then CandidatesJoined := CandidatesJoined + ', ';
          CandidatesJoined := CandidatesJoined + Spec.SourceCandidates[I];
        end;
        raise EDataMappingError.Create(
          Spec.TargetMember,
          CandidatesJoined,
          'n/a',
          'n/a',
          Format('Chyba mapování: požadované pole pro %s nenalezeno. Kandidáti: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // volitelné: prostě přeskočit
    end;

    Raw := F.Value; // Variant; bere v úvahu NULL
    if Assigned(FOnDebug) then
      FOnDebug(Spec.TargetMember, F.FieldName, F.DataType, Raw);

    try
      if Assigned(Spec.Converter) then
        Val := Spec.Converter(Raw)
      else
        Val := Raw;

      // Povinné: NULL po konvertoru je chyba (častěji, než byste čekali)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Chyba mapování: %s je povinné, ale hodnota je NULL po konverzi. Pole %s (%s), surová hodnota=%s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw)]));
      end;

      Assign(Spec.TargetMember, Val);

    except
      on E: EDataMappingError do
        raise;
      on E: Exception do
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Chyba mapování pro %s z pole %s (%s), surová hodnota=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Konverter }

function C_TrimToNull: TFieldConverter;
begin
  Result := function(const V: Variant): Variant
  var
    S: string;
  begin
    if VarIsNull(V) or VarIsEmpty(V) then Exit(Null);
    S := Trim(VarToStr(V));
    if S = '' then
      Result := Null
    else
      Result := S;
  end;
end;

function C_ZeroToNull: TFieldConverter;
begin
  Result := function(const V: Variant): Variant
  var
    N: Int64;
  begin
    if VarIsNull(V) or VarIsEmpty(V) then Exit(Null);
    // toleruje také '0' jako řetězec
    N := StrToInt64(Trim(VarToStr(V)));
    if N = 0 then
      Result := Null
    else
      Result := N;
  end;
end;

function C_StrictInt: TFieldConverter;
begin
  Result := function(const V: Variant): Variant
  begin
    if VarIsNull(V) or VarIsEmpty(V) then Exit(Null);
    Result := StrToInt(Trim(VarToStr(V)));
  end;
end;

function C_DateFromStringOrNull: TFieldConverter;
begin
  Result := function(const V: Variant): Variant
  var
    S: string;
    D: TDateTime;
  begin
    if VarIsNull(V) or VarIsEmpty(V) then Exit(Null);
    S := Trim(VarToStr(V));
    if (S = '') or (S = '1899-12-30') then Exit(Null);

    // Úmyslně přísné: žádné "Try", které by potlačilo kvalitu dat.
    // Formát se může lišit podle legacy; případně parametrizovat zde přes TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Jak prakticky používat Mapper (bez RTTI, přesto elegantně)

Der Mapper ruft eine Assign(TargetMember, Value)-Callback-Funktion auf. Das hält die Zuweisung explizit (und damit gut debugbar) und vermeidet RTTI-Zugriffe im Hot-Path. In der Praxis bauen Sie pro Objekt/DTO (Data Transfer Object, also ein Transportobjekt für Daten) einen kleinen „Zuweiser“.

Delphi
type
  TCustomer = class
  public
    Id: Integer;
    ExternalNo: string;
    DisplayName: string;
    BirthDate: TDateTime; // optional in Legacy
  end;

function MapCustomer(DS: TDataSet; Mapper: TLegacyDatasetMapper): TCustomer;
var
  C: TCustomer;
  Specs: TArray<TFieldSpec>;
begin
  C := TCustomer.Create;
  try
    Specs := [
      TFieldSpec.Create('Id', ['CUSTOMER_ID', 'ID', 'C_ID'], mrRequired, C_StrictInt),
      TFieldSpec.Create('ExternalNo', ['EXT_NO', 'CUSTOMERNO'], mrOptional, C_TrimToNull),
      TFieldSpec.Create('DisplayName', ['NAME', 'DISPLAYNAME', 'C_NAME'], mrRequired, C_TrimToNull),
      TFieldSpec.Create('BirthDate', ['BIRTHDATE', 'DOB'], mrOptional, C_DateFromStringOrNull)
    ];

    Mapper.MapOne(DS, Specs,
      procedure(const Target: string; const V: Variant)
      begin
        if Target = 'Id' then
          C.Id := V
        else if Target = 'ExternalNo' then
          C.ExternalNo := VarToStrDef(V, '')
        else if Target = 'DisplayName' then
          C.DisplayName := VarToStr(V)
        else if Target = 'BirthDate' then
        begin
          if VarIsNull(V) then
            C.BirthDate := 0
          else
            C.BirthDate := V;
        end
        else
          raise EInvalidOperation.Create('Unbekanntes TargetMember: ' + Target);
      end);

    Result := C;
  except
    C.Free;
    raise;
  end;
end;

Účel: Mapování je na jednom místě centrálně popsáno (Specs), ale přiřazení zůstává explicitní. V legacy situacích je to většinou lepší kompromis než plně automatické RTTI-mapování, protože okamžitě vidíte, která Property závisí na kterém názvu pole.

Předpoklady: Tento přístup očekává aktivní Dataset a aktuální pozici záznamu. Pro dávkové importy iterujete zvenčí přes while not DS.Eof do a voláte MapCustomer pro každý řádek.

Úskalí: Dbejte na VarToStr u BLOBů nebo memo polí; tam byste měli použít vlastní konvertory. A: „Required“ zde znamená po konvertoru. Pokud C_TrimToNull nastaví Required-pole na Null, je to záměrně – kvalitu dat je třeba řešit u zdroje nebo v procesu.

Varianty: Místo stringových Targetů můžete také použít Enum, abyste vyloučili překlepy. Alternativně lze Assign-funkci pro každý Spec uložit jako TProc<Variant>, pak Target-string zcela odpadne (o něco více boilerplate, ale méně chybových tříd).

Zařazení do architektury: DAL/Repository, Logging a provoz

V vrstvené architektuře (typicky: UI – Business – Datenzugriff) patří toto mapování do vrstvy přístupu k datům nebo do repository. Důležité je, aby se Dataset „nepropagoval“ dál: objekty/DTO jsou stabilnější rozhraní, zvláště pokud později doplníte REST-APIs nebo části přenesete do C# Services.

Pro provoz a podporu se vyplatí debugovací hook OnDebug. S ním můžete v testech nebo při reprodukovatelných podpůrných případech protokolovat, která pole byla skutečně namapována. V produkčních systémech by to mělo být cílené a vypínatelné, jinak bude logování příliš nákladné nebo datově náročné.

Smysluplné použití debug-hooku

  • Unit-testy: Ověřit, zda konkrétní SQL dotaz opravdu vrací všechna povinná (Required) pole.
  • Diagnostika: Při problémech u zákazníka hned poznáte „Pole tu nebylo“ vs. „Hodnotu nebylo možné konvertovat“.
  • Fáze migrace: Při přejmenovávání Views/sloupců můžete paralelně udržovat seznamy kandidátů, dokud není vše přesunuto.

Kdy tento přístup selhává (a co je pak lepší)

Ukázané mapování dataset→objekt je silné, když je zdroj dat neklidný a přesto potřebujete deterministické chování. Typicky ztrácí výhodu v dvou situacích:

  • Velká množství (např. hromadný export): konverze Variant a vyhledávání podle názvu pole mohou začít být znatelné. V takovém případě se vyplatí předpočítávat cache indexů polí pro každé SQL (např. FieldByName jednorázově pro Dataset, ne pro řádek).
  • Velké množství DTO‑typů: Když napíšete stovky mapperů, stane se z toho boilerplate téma. Pak může být smysluplný RTTI‑založený přístup s atributy – ale pouze pokud přísně kontrolujete debug výstupy a konvertory.

Dobrým kompromisem je: řešení rozpoznávání polí a konverze jako zde (explicitní, tolerantní k chybám tam, kde je třeba), ale s generovaným kódem (např. přes interní šablony) místo ručně psaného.

Závěr: Stabilita díky explicitním pravidlům – s jasnými hranicemi použití

U legacy datasetů s aliasy, volitelnými sloupci a historickou null‑semantikou je mapování dataset→objekt úspěšné především tehdy, když zůstane explicitní a schopné diagnostiky. Mapping‑plán složený z kandidátních seznamů, povinné/volitelné (Required/Optional) a konvertorů dělá přesně to: můžete technický dluh stabilizovat postupně, aniž byste hned zaváděli ORM nebo databázi „najednou“ normalizovali.

Limity se projeví u extrémních požadavků na výkon a při velmi mnoha tipech – v tom případě potřebujete caching nebo automatizované generování kódu. Pro typický podnikový software s vyvinutými procesy je tento přístup však spolehlivým nástrojem, jak opět oddělit přístup k datům a doménové modely a zlepšit jejich udržovatelnost.

Pokud u konkrétního legacy‑mapování (FireDAC, Views, nepořádek JOINů, Null‑Semantik) potřebujete druhý názor nebo spolehlivou cílovou architekturu, je dalším krokem obvykle krátká analýza s reprodukovatelnými příklady. Kontakt:

V odborném kontextu hrají také Delphi Dataset Mapping a Legacy Delphi důležitou roli, pokud musí integrace, datové toky a další vývoj správně spolupracovat.

Projednat projekt nebo modernizační záměr 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.