Net-Base Magazyn

10.05.2026

Mapowanie zbioru danych na obiekt dla nietypowych struktur legacy: stabilne, debugowalne, bez magii ORM

Jeśli legacy-datasety powstały historycznie, standardowe mapery często zawodzą na kolumnach z aliasami, mieszaniu typów i zmieniających się strukturach łączeń. Ten fragment źródła pokazuje odporne, łatwe do debugowania mapowanie zestawu danych do obiektu w Delphi: z planem mapowania, konwerterami i semantyką NULL...

10.05.2026

W dojrzałych Delphi-systemach mapowanie Dataset-zu-Objekt rzadko jest czystym przypadkiem „jedno pole = jedna właściwość”. W indywidualnym oprogramowaniu korporacyjnym natrafiają Państwo zamiast tego na kolumny-aliasy z widoków, wyniki JOIN z zduplikowanymi nazwami pól, „puste” wartości jako 0 lub ' ', pola typowane, które dziś zwracają VARCHAR, a jutro INTEGER, oraz kolumny, które zależnie od dialogu wyszukiwania po prostu nie występują. To właśnie tam wiele mapperów zawodzi: albo stają się „magiczne” (a więc trudne do debugowania), albo są tak restrykcyjne, że już jedno pole opcjonalne zatrzymuje działanie.

Ten fragment źródłowy pokazuje pragmatyczny mapper dla Delphi, który świadomie nie jest ORM, lecz w czysty sposób adresuje najważniejsze przypadki brzegowe w systemach legacy: jednoznaczne rozwiązywanie pól, kontrolowana konwersja, semantyka NULL, pola opcjonalne oraz przejrzyste komunikaty błędów. Nadaje się do warstwy Data-Access-Layer (DAL, czyli warstwa kapsułkująca dostęp do danych) lub wzorca Repository i dobrze komponuje się z BDE-Ablosung mit nativer Anbindung (Delphis Datenzugriffsbibliothek für viele DBs).

Dlaczego standardowe mapowanie zawodzi w istniejących strukturach

Kilka typowych przyczyn z eksploatacji, których rzadko spotyka się przy „czystym” nowym projekcie:

  • Wieloznaczne nazwy pól: JOIN zwraca ID z kilku tabel; w zestawie danych pojawia się wtedy ID, ID_1 lub nazwa jest zmieniona przez alias SQL.
  • Semantyczne NULL-e: 0 oznacza „nieznane”, '1899-12-30' to „brak daty”, ' ' to „nieuzupełnione”.
  • Zmienne typy: Widok nie wykonuje rzutowania; sterownik zwraca ftWideString zamiast ftInteger. Konwersja wariantów staje się źródłem błędów.
  • Pola opcjonalne: Dialog wyszukiwania używa w zależności od filtra innych list SELECT. Kod jednak oczekuje pól „zawsze”.
  • Możliwość debugowania: Jeśli mapowanie znika w RTTI, analiza błędów w danych klienta jest trudna (które pole, jaka wartość, jaki typ?).

Podejście: plan mapowania zamiast konwencji, z kontrolowaną konwersją

Rdzeniem jest plan mapowania: lista reguł „właściwość X pochodzi z pola A lub B, jest opcjonalna/wymagana, używa konwertera Y”. Dzięki temu mapowanie pozostaje deklaratywne, ale nie „niewidoczne” jak w wielu mechanizmach ORM. Dodatkowo mapper może dla każdego pola rzucać czytelną wyjątkową informację, zawierającą nazwę pola, typ danych i surową wartość.

Ważne: mapujemy celowo z TDataSet, a nie z konkretnej klasy BDE-Ablosung mit nativer Anbindung. Dzięki temu pozostaje kompatybilne z TFDQuery, TClientDataSet oraz komponentami zewnętrznymi.

Fragment źródłowy: debugowalne mapowanie Dataset-zu-Objekt dla kolumn legacy

Kod implementuje:

  • Rozwiązywanie pól za pomocą listy priorytetów (Aliases/Fallbacks)
  • Obsługę pól wymaganych/opcjonalnych
  • Semantykę NULL przez konwertery (np. 0 => Null)
  • Stabilne komunikaty błędów z kontekstem
  • Hook do debugowania, umożliwiający odtworzenie problemów z mapowaniem w testach lub przy wsparciu
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);

  // Konwerter otrzymuje Variant i zwraca Variant (np. 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: wywołuje setter dla każdej specyfikacji. Brak RTTI: jawne przypisanie jest łatwiejsze do debugowania.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Konwertery pomocnicze
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 zamiast FieldByName: możliwe opcjonalnie, bez wyjątku
    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 nie jest aktywny.');

  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('Błąd mapowania: wymagane pole dla %s nie znalezione. Kandydaci: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // opcjonalnie: po prostu pominąć
    end;

    Raw := F.Value; // Variant; uwzględnia 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;

      // Required: Null po konwersji to błąd (częstsze niż się wydaje)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Błąd mapowania: %s jest wymagane, jednak wartość jest NULL po konwersji. Pole %s (%s), wartość surowa=%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('Błąd mapowania przy %s z pola %s (%s), wartość surowa=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Konwertery }

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że '0' jako ciąg znaków
    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);

    // Celowo restrykcyjnie: brak "Try", który mógłby ukryć problemy z jakością danych.
    // Format może się różnić w zależności od systemu legacy; w razie potrzeby parametryzować tu przez TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Jak praktycznie używać Mappera (bez RTTI, ale wciąż elegancko)

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;

Zweck: Mapowanie jest opisane centralnie w jednym miejscu (Specs), ale przypisania pozostają jawne. W sytuacjach Legacy jest to zwykle lepszy kompromis niż w pełni automatyczne mapowanie RTTI, ponieważ od razu widać, która właściwość zależy od których nazw pól.

Randbedingungen: Podejście zakłada aktywny Dataset i aktualną pozycję rekordu. Für Batch-Importe iterieren Sie außen über while not DS.Eof do und rufen MapCustomer pro Row auf.

Stolperfallen: Zwróć uwagę na VarToStr przy BLOBs lub polach Memo; w tych miejscach powinieneś użyć własnych konwerterów. Und: „Required“ bedeutet hier nach Konverter. Wenn C_TrimToNull ein Required-Feld auf Null setzt, ist das Absicht – Datenqualität muss dann an der Quelle oder im Prozess geklärt werden.

Varianten: Zamiast String-Targets można też użyć Enum, żeby wyeliminować literówki. Alternatywnie lässt sich die Assign-Funktion pro Spec als TProc<Variant> speichern, dann entfällt der Target-String komplett (etwas mehr Boilerplate, dafür noch weniger Fehlerklasse).

Einordnung in Architektur: DAL/Repository, Logging und Betrieb

W architekturze warstwowej (typowo: UI – Business – dostęp do danych) to mapowanie należy do warstwy dostępu do danych lub do repository. Wichtig ist, dass das Dataset nicht „durchgereicht“ wird: Objekte/DTOs sind die stabilere Schnittstelle, gerade wenn Sie später REST-APIs nachrüsten oder Teile in C# Services auslagern.

Dla eksploatacji i wsparcia opłaca się używać debug-hooka OnDebug. Można dzięki niemu w testach lub przy powtarzalnych przypadkach wsparcia rejestrować, które pola rzeczywiście zostały zmapowane. W systemach produkcyjnych powinno to być stosowane selektywnie i możliwe do wyłączenia, w przeciwnym razie logowanie stanie się zbyt kosztowne lub zbyt obciążające danymi.

Rozsądne wykorzystanie Debug-Hook

  • Testy jednostkowe: Sprawdzić, czy konkretne zapytanie SQL rzeczywiście dostarcza wszystkie pola wymagane.
  • Diagnostyka: Przy problemach u klienta widzisz od razu „pole nie było obecne” vs. „wartości nie dało się skonwertować”.
  • Fazy migracji: Przy przełączaniu widoków/nazw kolumn można prowadzić równoległe listy kandydatów, aż wszystko zostanie przeniesione.

Kiedy to podejście się nie sprawdza (i co wtedy jest lepsze)

Przedstawione mapowanie z Datasetu na obiekt jest silne, gdy źródło danych jest niestabilne, a mimo to potrzebujesz deterministycznego zachowania. Zwykle zawodzi w dwóch sytuacjach:

  • Bardzo duże wolumeny (np. eksport masowy): konwersje Variant i wyszukiwanie po nazwie pola mogą stać się odczuwalne. Wtedy opłaca się wstępnie obliczany cache indeksu pól dla każdego SQL (np. FieldByName jednokrotnie na Dataset, nie na wiersz).
  • Duża liczba typów DTO: Jeśli piszesz setki mapperów, boilerplate staje się problemem. Wówczas sensowne może być podejście oparte na RTTI z atrybutami — ale tylko jeśli ściśle kontrolujesz wyjścia debugowe i konwertery.

Dobry kompromis to: rozwiązywanie pól i konwersja jak tutaj (jawnie, tolerancyjnie tam, gdzie trzeba), ale z generowanym kodem (np. przez wewnętrzne szablony) zamiast „ręcznie pisanego”.

Wnioski: stabilność dzięki jawnym regułom — z jasnymi granicami zastosowania

W przypadku legacy-Datasetów z aliasami, opcjonalnymi kolumnami i historyczną semantyką NULL mapowanie z Datasetu na obiekt jest szczególnie skuteczne wtedy, gdy pozostaje jawne i możliwe do diagnozy. Plan mapowania oparty na listach kandydatów, oznaczeniu wymagane/opcjonalne i konwerterach zapewnia dokładnie to: możesz stopniowo stabilizować zaległości historyczne, bez od razu wprowadzania ORM ani jednorazowej normalizacji bazy danych.

Granice pojawiają się przy ekstremalnych wymaganiach wydajnościowych oraz przy bardzo wielu typach — wtedy potrzebne jest cachowanie lub zautomatyzowane generowanie kodu. Dla typowego oprogramowania biznesowego z ugruntowanymi procesami podejście to jest jednak solidnym narzędziem, pozwalającym ponownie rozdzielić dostęp do danych i modele domenowe oraz uczynić je łatwiejszymi w utrzymaniu.

Jeśli przy konkretnym mapowaniu legacy (FireDAC, widoki, rozrost joinów, semantyka NULL) potrzebujesz drugiej opinii lub rzetelnej architektury docelowej, zwykle następnym krokiem jest krótka analiza z reprodukowalnymi przykładami. Kontakt:

W obszarze merytorycznym istotną rolę odgrywają też Delphi Dataset Mapping i Legacy Delphi, gdy integracje, przepływy danych i dalszy rozwój muszą współgrać w uporządkowany sposób.

Omów projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.