Net-Base Magazin

10.05.2026

Dataset-zu-Objekt Mapping für ungewöhnliche Altstrukturen: stabil, debugbar, ohne ORM-Magie

Wenn Legacy-Datasets historisch gewachsen sind, brechen Standard-Mapper oft an Alias-Spalten, Typ-Mischungen und wechselnden Join-Strukturen. Dieser Source-Schnipsel zeigt ein robustes, debugbares Dataset-zu-Objekt Mapping in Delphi: mit Mapping-Plan, Konvertern, Null-Semantik...

10.05.2026

Bei gewachsenen Delphi-Systemen ist Dataset-zu-Objekt Mapping selten der saubere „ein Feld = eine Property“-Fall. In individueller Unternehmenssoftware treffen Sie stattdessen auf Alias-Spalten aus Views, Join-Ergebnisse mit doppelten Feldnamen, „leere“ Werte als 0 oder ' ', typisierte Felder, die heute VARCHAR und morgen INTEGER liefern, und Spalten, die je nach Suchdialog einfach nicht dabei sind. Genau dort kippen viele Mapper: Entweder werden sie zu „magisch“ (und damit schwer debugbar), oder sie sind so strikt, dass schon ein optionales Feld den Betrieb stoppt.

Dieser Source-Schnipsel zeigt einen pragmatischen Mapper für Delphi, der bewusst kein ORM ist, aber die wichtigsten Legacy-Randfälle sauber adressiert: eindeutige Feldauflösung, kontrollierte Konvertierung, Null-Semantik, optionale Felder und nachvollziehbare Fehlermeldungen. Er eignet sich für Data-Access-Layer (DAL, also eine Schicht, die Datenzugriff kapselt) oder Repository-Patterns – und lässt sich gut mit BDE-Ablosung mit nativer Anbindung (Delphis Datenzugriffsbibliothek für viele DBs) kombinieren.

Warum Standard-Mapping bei Altstrukturen scheitert

Ein paar typische Ursachen aus dem Betrieb, die man bei „sauberem“ Neudesign selten sieht:

  • Mehrdeutige Feldnamen: Join liefert ID aus mehreren Tabellen; im Dataset heißt es dann ID, ID_1 oder ist per SQL-Alias umbenannt.
  • Semantische Nulls: 0 bedeutet „unbekannt“, '1899-12-30' ist „kein Datum“, ' ' ist „nicht gepflegt“.
  • Schwankende Typen: Ein View castet nicht; der Treiber liefert ftWideString statt ftInteger. Variant-Konvertierung wird zur Fehlerquelle.
  • Optionale Spalten: Ein Suchdialog nutzt je nach Filter andere SELECT-Listen. Code erwartet aber Felder „immer“.
  • Debuggability: Wenn Mapping in RTTI verschwindet, ist die Fehlersuche bei Kunden-Daten schwierig (welches Feld, welcher Wert, welcher Typ?).

Ansatz: Mapping-Plan statt Konvention, mit kontrollierter Konvertierung

Der Kern ist ein Mapping-Plan: eine Liste von Regeln „Property X kommt aus Feld A oder B, ist optional/required, nutzt Konverter Y“. Damit bleibt das Mapping deklarativ, aber nicht „unsichtbar“ wie bei vielen ORM-Mechanismen. Außerdem kann der Mapper pro Feld eine aussagekräftige Ausnahme werfen, inklusive Feldnamen, Datentyp und Rohwert.

Wichtig: Wir mappen absichtlich aus TDataSet, nicht aus einer konkreten BDE-Ablosung mit nativer Anbindung-Klasse. Damit bleibt es kompatibel zu TFDQuery, TClientDataSet oder auch Fremdkomponenten.

Source-Schnipsel: Debugbares Dataset-zu-Objekt Mapping für Legacy-Spalten

Der Code implementiert:

  • Feldauflösung über eine Prioritätenliste (Aliases/Fallbacks)
  • Required/Optional-Handling
  • Null-Semantik über Konverter (z. B. 0 => Null)
  • Stabile Fehlermeldungen mit Kontext
  • Einen Debug-Hook, um Mapping-Probleme im Test oder im Support-Fall nachvollziehen zu können
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);

  // Konverter erhält Variant und liefert Variant (z. B. Null, Integer, String, TDateTime als 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: ruft Setter für jede Spec auf. Kein RTTI: explizite Zuweisung ist besser debugbar.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Hilfs-Konverter
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 statt FieldByName: optional möglich, ohne Exception
    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 ist nicht aktiv.');

  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('Mapping-Fehler: Required-Feld für %s nicht gefunden. Kandidaten: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // optional: schlicht überspringen
    end;

    Raw := F.Value; // Variant; berücksichtigt 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 nach Konverter ist ein Fehler (häufiger als man denkt)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Mapping-Fehler: %s ist Required, aber Wert ist NULL nach Konvertierung. Feld %s (%s), Rohwert=%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('Mapping-Fehler bei %s aus Feld %s (%s), Rohwert=%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);
    // toleriert auch '0' als String
    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);

    // Absichtlich strikt: kein "Try" verschluckt Datenqualität.
    // Format kann je nach Legacy variieren; ggf. hier über TFormatSettings parametrieren.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Wie man den Mapper praktisch nutzt (ohne RTTI, aber trotzdem elegant)

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: Das Mapping ist an einer Stelle zentral beschrieben (Specs), aber die Zuweisung bleibt explizit. In Legacy-Situationen ist das meist die bessere Trade-off-Entscheidung als ein vollautomatisches RTTI-Mapping, weil Sie sofort sehen, welche Property von welchen Feldnamen abhängt.

Randbedingungen: Der Ansatz erwartet ein aktives Dataset und eine aktuelle Record-Position. Für Batch-Importe iterieren Sie außen über while not DS.Eof do und rufen MapCustomer pro Row auf.

Stolperfallen: Achten Sie auf VarToStr bei BLOBs oder Memo-Feldern; dort sollten Sie eigene Konverter nutzen. 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: Statt String-Targets können Sie auch ein Enum verwenden, um Tippfehler auszuschließen. Alternativ 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

In einer Layer-Architektur (typisch: UI – Business – Datenzugriff) gehört dieses Mapping in die Datenzugriffsschicht oder in ein 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.

Für Betrieb und Support lohnt sich der Debug-Hook OnDebug. Sie können damit in Tests oder bei reproduzierbaren Supportfällen protokollieren, welche Felder tatsächlich gemappt wurden. In produktiven Systemen sollte das gezielt und abschaltbar sein, sonst wird Logging zu teuer oder zu datenhaltig.

Debug-Hook sinnvoll nutzen

  • Unit-Tests: Prüfen, ob ein bestimmtes SQL-Statement wirklich alle Required-Felder liefert.
  • Diagnose: Bei Kundenproblemen sehen Sie sofort „Feld war nicht da“ vs. „Wert konnte nicht konvertiert werden“.
  • Migrationsphasen: Beim Umstellen von Views/Spaltennamen können Sie Kandidatenlisten parallel pflegen, bis alles umgezogen ist.

Wann dieser Ansatz kippt (und was dann besser ist)

Das gezeigte Dataset-zu-Objekt Mapping ist stark, wenn die Datenquelle unruhig ist und Sie trotzdem deterministisches Verhalten brauchen. Es kippt typischerweise in zwei Situationen:

  • Sehr große Mengen (z. B. Massenexport): Variant-Konvertierung und per Feldname suchen kann spürbar werden. Dann lohnt sich ein vorberechnetes Feldindex-Caching pro SQL (z. B. FieldByName einmalig pro Dataset, nicht pro Row).
  • Sehr viele DTO-Typen: Wenn Sie hunderte Mapper schreiben, wird Boilerplate zum Thema. Dann kann ein RTTI-basierter Ansatz mit Attributen sinnvoll sein – aber nur, wenn Sie Debug-Ausgaben und Konverter strikt kontrollieren.

Ein guter Zwischenweg ist: Feldauflösung und Konvertierung wie hier (explizit, fehlertolerant wo nötig), aber mit generiertem Code (z. B. über interne Templates) statt „handgeschrieben“.

Fazit: Stabilität durch explizite Regeln – mit klaren Einsatzgrenzen

Bei Legacy-Datasets mit Aliases, optionalen Spalten und historischer Null-Semantik ist Dataset-zu-Objekt Mapping vor allem dann erfolgreich, wenn es explizit und diagnosefähig bleibt. Der Mapping-Plan aus Kandidatenlisten, Required/Optional und Konvertern schafft genau das: Sie können Altlasten schrittweise stabilisieren, ohne gleich ein ORM einzuführen oder die Datenbank „auf einmal“ zu normalisieren.

Die Grenzen liegen bei Extrem-Performance und bei sehr vielen Typen – dann brauchen Sie Caching oder automatisierte Code-Erzeugung. Für typische Business-Software mit gewachsenen Prozessen ist der Ansatz jedoch ein verlässlicher Hebel, um Datenzugriff und Domänenmodelle wieder entkoppelt und wartbar zu bekommen.

Wenn Sie bei einem konkreten Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) eine zweite Meinung oder eine belastbare Zielarchitektur brauchen, ist der nächste Schritt meist eine kurze Analyse mit reproduzierbaren Beispielen. Kontakt:

Im fachlichen Umfeld spielen auch Delphi Dataset Mapping und Legacy Delphi eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. Für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.