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.
- Feste SELECT-Liste, saubere Spaltennamen? Dann reicht oft ein simples, explizites Mapping mit
FieldByNameund klaren Konvertern. - Optionale Spalten oder wechselnde Alias-Namen? Dann brauchen Sie Kandidatenlisten und
FindField, sonst wird das Mapping fragil. - Legacy-Null-Semantik (0/Leerstring/Sentinel-Datum)? Dann müssen Konverter Teil des Mapping-Plans sein, nicht „hier und da“ im UI.
- Sehr große Ergebnismengen oder Hot-Path? Dann sollten Sie Feldauflösung einmalig cachen (compiled plan), statt pro Row Namen zu suchen.
- 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
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.
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:
TDataSetund 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.