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 an Alias-Spalten, Typdrift und optionalen Spaltenlisten. Dieser Source-Schnipsel zeigt ein robustes, debugbares Dataset-zu-Objekt Mapping in Delphi: mit Mapping-Plan, Konvertern, Null-Semantik und...

10.05.2026

Vom Magazinthema zur Projektpraxis

Passende Leistungs- und Technikseiten zum Beitrag

Sie haben ein bestehendes Delphi-System, die Query ist „eigentlich stabil“ – und dann kommt die eine Auswertung dazu: neuer LEFT JOIN, zwei Spalten heißen plötzlich beide ID, ein View liefert ftWideString statt ftInteger, und ein alter Prozess codiert „kein Datum“ als 1899-12-30. Genau in dieser Gemengelage wird Dataset-zu-Objekt Mapping zur Entscheidung: Entweder Sie akzeptieren stilles Datenchaos im Domänenmodell, oder Sie ziehen eine harte, aber nachvollziehbare Grenze zwischen Datenzugriff und Business-Objekten.

Konstruierte, aber realistische Situation: Ein Suchdialog in einer individuellen Unternehmenssoftware bekommt je nach Filter andere SELECT-Listen (Performance-Gründe, historisch gewachsen). In der „einfachen“ Suche kommt NAME mit, in der erweiterten zusätzlich BIRTHDATE und EXT_NO. Der UI-Code möchte aber immer ein TCustomer füllen – und zwar so, dass Supportfälle debugbar sind: „Feld fehlt“ ist etwas anderes als „Wert konnte nicht konvertiert werden“.

Dieser Refresh zeigt einen Mapper, der bewusst kein ORM ist: kein automatisches RTTI-Durchscannen aller Properties, kein „Konvention statt Konfiguration“. Stattdessen: ein expliziter Mapping-Plan mit Kandidatenfeldern, Required/Optional, kontrollierten Konvertern und einem Debug-Hook. Die Entscheidungspfad-Struktur hilft Ihnen, den Ansatz passend zu skalieren – von „schnell stabilisieren“ bis „für Massendaten kompiliert“.

Entscheidungspfad: Welches Mapping brauchen Sie wirklich?

Bevor Code entsteht, lohnt sich eine kurze Einordnung. In Legacy-Modernisierungen kippen Mapping-Lösungen meist nicht am ersten Tag, sondern im Betrieb – wenn SQL, Views oder Treiberverhalten leicht driftet.

  1. Feste SELECT-Liste, saubere Spaltennamen? Dann reicht oft ein simples, explizites Mapping mit FieldByName und klaren Konvertern.
  2. Optionale Spalten oder wechselnde Alias-Namen? Dann brauchen Sie Kandidatenlisten und FindField, sonst wird das Mapping fragil.
  3. Legacy-Null-Semantik (0/Leerstring/Sentinel-Datum)? Dann müssen Konverter Teil des Mapping-Plans sein, nicht „hier und da“ im UI.
  4. Sehr große Ergebnismengen oder Hot-Path? Dann sollten Sie Feldauflösung einmalig cachen (compiled plan), statt pro Row Namen zu suchen.
  5. Viele DTO-Typen, aber wenig Zeit für Boilerplate? Dann kann RTTI helfen – allerdings nur, wenn Sie Debuggability erzwingen (Mapping-Protokoll, strikte Konverter, keine stillen Defaults).

Warum Standard-Mapping bei ungewöhnlichen Altstrukturen scheitert

Die typischen Bruchstellen sehen in Projekten erstaunlich ähnlich aus:

Legacy-Symptom Warum es kippt Was der Mapper leisten muss
Doppelte Feldnamen nach JOIN Treiber/SQL aliasen uneinheitlich (ID, ID_1, T1_ID) Kandidatenliste + deterministische Auswahl
Optionale Spalten je nach Dialog SELECT-Liste variiert für Performance Optional/Required-Regeln ohne Exceptions im Optional-Fall
Null-Semantik codiert 0, ' ', Sentinel-Datum statt DB-NULL Konverter, die Semantik zentral und testbar abbilden
Schwankende Feldtypen Views casten nicht; Treiber liefert String statt Integer Strikte Konverter + Fehler mit Rohwert und Typ

Zwei Anker, die in Delphi immer wieder unterschätzt werden: Erstens ist TDataSet.FindField der Hebel, um optionale Spalten ohne Exception zu behandeln (im Gegensatz zu FieldByName, das bei fehlenden Feldern wirft). Das Verhalten ist in der VCL/DB-Schicht dokumentiert und wird von Ableitungen wie BDE-Ablösung mit nativer Anbindung-Queries übernommen (Embarcadero DocWiki: TDataSet.FindField). Zweitens ist RTTI in Delphi ein mächtiges Werkzeug, aber es bringt Laufzeitkosten und Debugging-Komplexität – besonders, wenn Mappings „implizit“ werden (Embarcadero DocWiki: RTTI in Delphi).

Dataset-zu-Objekt Mapping als Mapping-Plan (ohne ORM-Magie)

Der Kern ist ein Plan aus Specs: Zielmember, Feldkandidaten, Required/Optional und ein Konverter. Die Zuweisung selbst bleibt explizit (Callback). Das wirkt zunächst „mehr Tipparbeit“, zahlt sich aber aus, wenn Sie driftende Altstrukturen über mehrere Releases kontrolliert modernisieren.

Source-Schnipsel: Mapper mit Kandidatenfeldern, Konvertern, Debug-Hook

Delphi
unit Legacy.DatasetMapper;

interface

uses
  System.SysUtils,
  System.Variants,
  System.TypInfo,
  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: Variant rein, Variant raus. Null-Semantik wird hier zentralisiert.
  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;
    function JoinCandidates(const C: TArray<string>): string;
  public
    property OnDebug: TMapDebugEvent read FOnDebug write FOnDebug;

    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Beispiel-Konverter
function C_TrimToNull: TFieldConverter;
function C_ZeroToNull: TFieldConverter;
function C_StrictInt: TFieldConverter;
function C_StrictIsoDateOrNull: 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.JoinCandidates(const C: TArray<string>): string;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to High(C) do
  begin
    if I > 0 then Result := Result + ', ';
    Result := Result + C[I];
  end;
end;

function TLegacyDatasetMapper.FindFieldByCandidates(DS: TDataSet; const Candidates: TArray<string>): TField;
var
  Name: string;
begin
  Result := nil;
  for Name in Candidates do
  begin
    Result := DS.FindField(Name); // keine Exception bei „Spalte fehlt“
    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;
  FT, Cand: 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
        Cand := JoinCandidates(Spec.SourceCandidates);
        raise EDataMappingError.Create(
          Spec.TargetMember, Cand, 'n/a', 'n/a',
          Format('Required-Mapping fehlt: %s. Kandidaten: [%s]', [Spec.TargetMember, Cand]));
      end;
      Continue; // optional: Feld ist nicht Teil des Resultsets
    end;

    Raw := F.Value;
    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;

      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw),
          Format('Required ist NULL nach Konvertierung: %s aus %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: %s aus %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);
    N := StrToInt64(Trim(VarToStr(V))); // toleriert auch '0'
    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))); // bewusst strikt
  end;
end;

function C_StrictIsoDateOrNull: 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);

    // Erwartet ISO-8601. Wenn Ihr Legacy-Format anders ist: hier zentral anpassen.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Zweck: Das Mapping ist deklarativ (Specs), aber nicht verborgen. Sie sehen pro Zielmember die Feldkandidaten und die Semantik.

Randbedingungen: Der Code mappt aus TDataSet (kompatibel zu TFDQuery, TClientDataSet etc.). Er setzt ein aktives Dataset und eine gültige Record-Position voraus.

Stolperfallen: VarToStr auf Memo/BLOB ist nicht immer sinnvoll; dafür eigene Konverter bauen. Und: „Required“ wird nach dem Konverter geprüft – das ist Absicht, sonst bleibt Legacy-Datenqualität unsichtbar.

Praktische Nutzung: Explizite Zuweisung statt RTTI-Hokuspokus

Die Assign-Callback-Lösung wirkt altmodisch, ist aber im Debugger unschlagbar: Breakpoint in der Zuweisung, Sie sehen TargetMember, Rohwert, konvertierten Wert. Gerade in prozessnahen Softwarelösungen mit Supportdruck zählt diese Transparenz.

Delphi
type
  TCustomer = class
  public
    Id: Integer;
    ExternalNo: string;
    DisplayName: string;
    BirthDate: TDateTime; // optional
  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_StrictIsoDateOrNull)
    ];

    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;

Debug-Hook: Support-tauglich, aber kontrolliert

Der Debug-Hook ist bewusst „roh“ (Feldname, TFieldType, Variant). Damit können Sie in Tests prüfen, ob Required-Felder wirklich im Resultset sind, oder in einer Diagnoseausgabe zeigen, welche Kandidaten tatsächlich gezogen haben. Im Betrieb sollte das Logging gezielt und abschaltbar sein, sonst drohen Performance- und Datenschutzprobleme (personenbezogene Inhalte in Logs).

Wenn Performance zählt: „Compiled Mapping Plan“ (Feld-Caching)

Der oben gezeigte Plan sucht pro Row per Feldname. Das ist für viele Business-Software-Fälle völlig ok, aber bei großen Ergebnismengen messbar. Dann bauen Sie beim Öffnen der Query eine kompakte Struktur, die pro Spec direkt den TField referenziert (oder nil). Pro Row wird nur noch F.Value gelesen.

Wichtig: Das Caching ist nur gültig, solange die Feldliste des Datasets stabil bleibt (gleiches SQL/gleiche Query-Instanz). Bei dynamischen SELECT-Listen muss der compiled plan neu erstellt werden.

Risiken, Grenzen und wann Sie anders entscheiden sollten

  • Konverter als „Mini-Parser“: Wenn Sie anfangen, in Konvertern zu viel zu reparieren (TryParse mit Fallbacks), verschieben Sie Datenqualität still ins Mapping. Das rächt sich bei Audits und bei späteren Schnittstellen.
  • Zu viele DTOs: Bei sehr vielen Typen wird die Zuweisungslogik Boilerplate. Dann sind Template-Generierung oder ein RTTI-Ansatz mit Attributen sinnvoll – aber nur, wenn Sie weiterhin pro Feld Konverter, Required-Regeln und gute Fehlermeldungen erzwingen.
  • Threading: TDataSet und viele DB-Komponenten sind nicht thread-safe. Mapping im Worker-Thread ist ok, wenn das Dataset dort erzeugt und genutzt wird; ein „Dataset aus dem UI-Thread mappen“ ist ein Klassiker für sporadische Fehler.

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

Dataset-zu-Objekt Mapping für ungewöhnliche Altstrukturen ist kein Schönheitswettbewerb, sondern eine Betriebssicherung: Kandidatenlisten, Konverter und Required/Optional machen aus driftenden SQL-Resultsets wieder eine kontrollierbare Schnittstelle. Der gezeigte Mapping-Plan ist absichtlich transparent: Debugger-freundlich, testbar und gut zu erweitern (z. B. weitere Konverter, Feld-Caching, Enum-TTargets).

Die Grenzen liegen dort, wo Sie entweder extremen Durchsatz brauchen (dann compiled plan) oder Hunderte DTOs pflegen müssen (dann Code-Generierung oder ein streng geführter RTTI-Ansatz). Wenn Sie in einer Delphi-Modernisierung gerade an einer Mapping-Kante festhängen (BDE-Ablösung mit nativer Anbindung, Views, Alias-Spalten, Null-Semantik), lässt sich das in einer kurzen Analyse meist sauber eingrenzen – inklusive Entscheidung, ob explizites Mapping, Feld-Caching oder ein anderer Layer-Schnitt besser passt. Kontakt aufnehmen und das konkrete Mapping-Problem besprechen.

Für dieses Thema sind auch Delphi Dataset Mapping und Legacy Delphi wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Bestand, Zielbild und technische Risiken werden zusammen bewertet.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

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.