Bij gegroeide Delphi-systemen is Dataset-naar-object-mapping zelden het zuivere ‚één veld = één property‘-geval. In maatwerk bedrijfssoftware komt u in plaats daarvan aliaskolommen uit views, join-resultaten met dubbele veldnamen, „lege“ waarden als 0 of ' ', getypeerde velden die vandaag VARCHAR en morgen INTEGER leveren, en kolommen die afhankelijk van de zoekdialoog gewoonweg ontbreken. Juist daar haken veel mappers af: of ze worden te „magisch“ (en daarmee moeilijk te debuggen), of ze zijn zo strikt dat al een optioneel veld de werking stillegt.
Dit bronfragment toont een pragmatische mapper voor Delphi, die bewust geen ORM is, maar de belangrijkste legacy-randgevallen netjes aanpakt: eenduidige veldresolutie, gecontroleerde conversie, null-semantiek, optionele velden en controleerbare foutmeldingen. Het is geschikt voor Data-Access-Layer (DAL, dus een laag die datatoegang kapselt) of repository-patterns – en combineert goed met een BDE-vervanging met native koppeling (Delphis bibliotheek voor gegevenstoegang voor veel DBs).
Waarom standaardmapping bij legacy-structuren faalt
Een paar typische oorzaken uit de operatie, die men bij een „schoon“ herontwerp zelden ziet:
- Dubbele veldnamen: Een join levert
IDuit meerdere tabellen; in het dataset heet het danID,ID_1of is het per SQL-alias hernoemd. - Semantische nullen:
0betekent ‚onbekend‘,'1899-12-30'is ‚geen datum‘,' 'is ’niet ingevuld‘. - Wisselende typen: Een view voert geen casts uit; de driver levert
ftWideStringin plaats vanftInteger. Variant-conversie wordt een foutbron. - Optionele kolommen: Een zoekdialoog gebruikt afhankelijk van filters andere SELECT-lijsten. De code verwacht velden echter ‚altijd‘.
- Debugbaarheid: Als mapping in RTTI verdwijnt, wordt foutzoeken bij klantdata moeilijk (welk veld, welke waarde, welk type?).
Aanpak: Mapping-Plan in plaats van conventie, met gecontroleerde conversie
De kern is een mappingplan: een lijst regels ‚Property X komt uit veld A of B, is optioneel/verplicht, gebruikt converter Y‘. Daarmee blijft het mapping declaratief, maar niet ‚onzichtbaar‘ zoals bij veel ORM-mechanismen. Daarnaast kan de mapper per veld een duidelijke uitzondering werpen, inclusief veldnaam, datetype en ruwe waarde.
Belangrijk: we mappen opzettelijk vanuit TDataSet, niet uit een concrete BDE-Ablosung mit nativer Anbindung-klasse. Daardoor blijft het compatibel met TFDQuery, TClientDataSet of ook componenten van derden.
Bronfragment: debugbaar Dataset-naar-object-mapping voor legacy-kolommen
De code implementeert:
- Veldresolutie via een prioriteitenlijst (aliases/fallbacks)
- Omgang met verplicht/optioneel
- Null-semantiek via converters (bijv.
0 => Null) - Stabiele foutmeldingen met context
- Een debug-hook om mappingproblemen in test- of supportgevallen reproduceerbaar te maken
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);
// Converter ontvangt een Variant en levert een Variant (bijv. 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: roept voor elke Spec de setter aan. Geen RTTI: expliciete toewijzing is beter te debuggen.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Hulpkonverters
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 in plaats van FieldByName: optioneel mogelijk, zonder 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 is niet actief.‘);
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-fout: vereist veld voor %s niet gevonden. Kandidaten: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // optioneel: gewoon overslaan
end;
Raw := F.Value; // Variant; houdt Null in rekening
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 na converter is een fout (vaker dan men 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-fout: %s is vereist, maar waarde is NULL na conversie. Veld %s (%s), ruwe waarde=%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-fout bij %s uit veld %s (%s), ruwe waarde=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konvertoren }
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);
// accepteert ook ‚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);
// Opzettelijk strikt: geen ‚Try‘ dat de datakwaliteit zou verbergen.
// Formaat kan per legacy variëren; eventueel hier parametriseren via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Hoe u de Mapper praktisch gebruikt (zonder RTTI, maar toch elegant)
De Mapper roept een Assign(TargetMember, Value)-callbackfunctie aan. Dat houdt de toewijzing expliciet (en daarmee goed te debuggen) en voorkomt RTTI-toegang in het hot-path. In de praktijk bouwt u per object/DTO (Data Transfer Object, dus een transportobject voor gegevens) een kleine „Zuweiser“.
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;Doel: De mapping is op één plek centraal beschreven (Specs), maar de toewijzing blijft expliciet. In Legacy-situaties is dat meestal de betere trade-off dan een volledig automatisch RTTI-mapping, omdat u direct ziet welke property van welke veldnamen afhankelijk is.
Randvoorwaarden: De aanpak verwacht een actief Dataset en een actuele recordpositie. Voor batch-imports iterereert u extern over while not DS.Eof do en roept u MapCustomer per rij aan.
Valkuilen: Let op VarToStr bij BLOBs of memo-velden; daar moet u eigen konverters gebruiken. En: “Required” betekent hier na de converter. Als C_TrimToNull een required-veld op Null zet, is dat opzettelijk – dat betekent dat datakwaliteit aan de bron of in het proces moet worden afgehandeld.
Varianten: In plaats van string-targets kunt u ook een enum gebruiken om typefouten uit te sluiten. Alternatief kunt u de Assign-functie per spec als TProc<Variant> opslaan; dan valt de target-string volledig weg (wat wat meer boilerplate is, maar nog minder foutkans oplevert).
Positionering in de architectuur: DAL/Repository, logging en exploitatie
In een gelaagde architectuur (typisch: UI – Business – gegevenslaag) hoort deze mapping in de gegevenslaag of in een repository. Belangrijk is dat het Dataset niet „doorgegeven“ wordt: objecten/DTOs zijn de stabielere interface, zeker als u later REST-API’s naadloos wilt toevoegen of delen wilt uitbesteden naar C# services.
Voor operatie en support is de debug-hook OnDebug de moeite waard. Daarmee kunt u in tests of bij reproduceerbare supportgevallen vastleggen welke velden daadwerkelijk zijn gemapt. In productiesystemen moet dit gericht en uitschakelbaar zijn, anders wordt logging te kostbaar of te data-intensief.
Debug-hook zinvol gebruiken
- Unit-tests: Controleren of een bepaald SQL-statement daadwerkelijk alle verplichte velden levert.
- Diagnose: Bij klantproblemen ziet u direct „veld ontbrak“ vs. „waarde kon niet worden geconverteerd“.
- Migratiefasen: Bij het omzetten van views/kolomnamen kunt u kandidaatlijsten parallel bijhouden totdat alles is verhuisd.
Wanneer deze aanpak kantelt (en wat dan beter is)
De getoonde dataset-naar-object-mapping is krachtig wanneer de gegevensbron onrustig is en u toch deterministisch gedrag nodig hebt. Het hapert typisch in twee situaties:
- Zeer grote hoeveelheden (bijv. massexport): Variant-conversie en zoeken per veldnaam kunnen merkbaar worden. Dan loont een vooraf berekende veldindex-caching per SQL (bijv.
FieldByNameéénmalig per dataset, niet per rij). - Zeer veel DTO-typen: Als u honderden mappers schrijft, wordt boilerplate een probleem. Dan kan een RTTI-gebaseerde aanpak met attributen zinvol zijn – maar alleen als u debug-uitvoer en converters strikt controleert.
Een goed middenweg is: veldresolutie en conversie zoals hier (expliciet, fouttolerant waar nodig), maar met gegenereerde code (bijv. via interne templates) in plaats van „met de hand geschreven“.
Conclusie: Stabiliteit door expliciete regels – met duidelijke toepassingsgrenzen
Bij Legacy-Datasets met aliassen, optionele kolommen en historische null-semantiek is dataset-naar-object-mapping vooral succesvol als het expliciet en diagnoseerbaar blijft. Het mapping-plan van kandidaatlijsten, verplicht/optioneel en converters levert precies dat: u kunt legacy-issues stapsgewijs stabiliseren, zonder meteen een ORM in te voeren of de database „in één keer“ te normaliseren.
De grenzen liggen bij extreme performance en bij zeer veel types – dan heeft u caching of geautomatiseerde codegeneratie nodig. Voor typische businesssoftware met gegroeide processen is de aanpak echter een betrouwbare hefboom om gegevenstoegang en domeinmodellen weer te ontkoppelen en onderhoudbaar te maken.
Als u bij een concreet Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) een second opinion of een goed onderbouwde doelarchitectuur nodig heeft, is de volgende stap meestal een korte analyse met reproduceerbare voorbeelden. Contact:
In de vakmatige context spelen ook Delphi Dataset Mapping en Legacy Delphi een belangrijke rol wanneer integraties, datastromen en verdere ontwikkeling naadloos moeten samenwerken.
Project oder Modernisierungsvorhaben mit Net-Base besprechen.