I voksede Delphi-systemer er Dataset-til-objekt Mapping sjældent det rene „et felt = en property“-tilfælde. I individuel virksomhedssoftware støder man i stedet på alias-kolonner fra views, join-resultater med dublerede feltnavne, „tomme“ værdier som 0 eller ' ', typede felter der i dag leverer VARCHAR og i morgen INTEGER, samt kolonner der afhængigt af søgedialogen simpelthen ikke er med. Netop dér fejler mange mapper: Enten bliver de for „magiske“ (og dermed svære at debugge), eller de er så strikte, at et valgfrit felt stopper driften.
Denne source-snip viser en pragmatisk mapper for Delphi, som bevidst ikke er et ORM, men som rent adresserer de vigtigste legacy-kanttilfælde: entydig feltopløsning, kontrolleret konvertering, null-semantik, valgfrie felter og efterviselige fejlmeddelelser. Den egner sig til Data-Access-Layer (DAL, altså et lag der kapsler dataadgang) eller repository-patterns – og kan kombineres godt med BDE-erstatning med native tilslutning (Delphis dataadgangsbibliotek for mange DBs).
Hvorfor standard-mapping fejler ved ældre strukturer
Et par typiske årsager fra driften, som man sjældent ser i et „rent“ nydesign:
- Tvetydige feltnavne: Join returnerer
IDfra flere tabeller; i datasættet hedder det såID,ID_1eller er omdøbt via et SQL-alias. - Semantiske nuller:
0betyder „ukendt“,'1899-12-30'er „intet dato“,' 'er „ikke udfyldt“. - Svingende typer: Et view caster ikke; driveren leverer
ftWideStringi stedet forftInteger. Variant-konvertering bliver en fejlkilde. - Valgfrie kolonner: En søgedialog bruger afhængigt af filtre forskellige SELECT-lister. Koden forventer dog felter „altid“.
- Debuggability: Når mapping forsvinder ind i RTTI, bliver fejlsøgning på kundedata vanskelig (hvilket felt, hvilken værdi, hvilken type?).
Tilgang: Mapping-plan frem for konvention, med kontrolleret konvertering
Kernen er en Mapping-Plan: en liste af regler „Property X kommer fra felt A eller B, er optional/required, bruger konverter Y“. Dermed forbliver mappingen deklarativ, men ikke „usynlig“ som i mange ORM-mekanismer. Derudover kan mapperen per felt kaste en sigende undtagelse, inklusive feltnavn, datatypen og råværdien.
Vigtigt: Vi mapper bevidst fra TDataSet, ikke fra en konkret BDE-Ablosung mit nativer Anbindung-klasse. Dermed forbliver det kompatibelt med TFDQuery, TClientDataSet eller også tredjepartskomponenter.
Source-Schnipsel: Debugbart Dataset-til-Objekt Mapping for legacy-kolonner
Koden implementerer:
- Feltopløsning via en prioriteringsliste (aliases/fallbacks)
- Required/Optional-håndtering
- Null-semantik via konvertere (f.eks.
0 => Null) - Stabile fejlmeddelelser med kontekst
- En debug-hook for at kunne efterspore mapping-problemer i test eller i support-sager
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 modtager Variant og returnerer Variant (fx Null, Integer, String, TDateTime som Double)
TFieldConverter = reference to function(const V: Variant): Variant;
TFieldSpec = record
TargetMember: string;
SourceCandidates: TArray
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
function FieldTypeToString(FT: TFieldType): string;
function VariantToDiag(const V: Variant): string;
public
property OnDebug: TMapDebugEvent read FOnDebug write FOnDebug;
// MapOne: kalder setter for hver spec. Ikke RTTI: eksplicit tildeling er lettere at debugge.
procedure MapOne(DS: TDataSet; const Specs: TArray
const Assign: TProc
end;
// Hjælpekonvertere
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
var
Name: string;
begin
Result := nil;
for Name in Candidates do
begin
// Brug FindField i stedet for FieldByName: valgbar uden exception
Result := DS.FindField(Name);
if Result <> nil then
Exit;
end;
end;
procedure TLegacyDatasetMapper.MapOne(DS: TDataSet; const Specs: TArray
const Assign: TProc
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 er ikke aktivt.‘);
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-fejl: Required-feltet for %s blev ikke fundet. Kandidater: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valgfri: bare spring over
end;
Raw := F.Value; // Variant; håndterer 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 efter konvertering er en fejl (hyppigere end man tror)
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-fejl: %s er Required, men værdien er NULL efter konvertering. Felt %s (%s), råværdi=%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-fejl ved %s fra felt %s (%s), råværdi=%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);
// tolererer også ‚0‘ som streng
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);
// Bevidst strikt: ingen ‚Try‘ der skjuler datakvalitet.
// Formatet kan variere afhængigt af legacy; eventuelt parameterisere her via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Hvordan man bruger mapperen i praksis (uden RTTI, men alligevel elegant)
Mapperen kalder en Assign(TargetMember, Value)-callbackfunktion. Det holder tildelingen eksplicit (og dermed godt debugbar) og undgår RTTI-adgang i Hot-Path. I praksis bygger man per objekt/DTO (Data Transfer Object, altså et transportobjekt for data) en lille „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;Formål: Mappinget er beskrevet centralt ét sted (Specs), men tildelingen forbliver eksplicit. I Legacy-situationer er det som regel den bedre trade-off end et fuldautomatiseret RTTI-mapping, fordi man straks kan se, hvilken property afhænger af hvilke feltnavne.
Forudsætninger: Tilgangen forudsætter et aktivt Dataset og en aktuel record-position. For batch-importer itererer man udenom over while not DS.Eof do og kalder MapCustomer for hver række.
Faldgruber: Vær opmærksom på VarToStr ved BLOBs eller memo-felter; der bør man bruge egne konvertere. Og: „Required“ betyder her efter konverteren. Hvis C_TrimToNull sætter et Required-felt til Null, er det tilsigtet – datakvaliteten må da afklares ved kilden eller i processen.
Varianter: I stedet for string-targets kan man også bruge et Enum for at udelukke tastefejl. Alternativt kan Assign-funktionen pr. Spec gemmes som TProc<Variant>, så Target-strengen falder helt bort (en smule mere boilerplate, men til gengæld færre fejl).
Indplacering i arkitektur: DAL/Repository, Logging og Betrieb
I en lagdelt arkitektur (typisk: UI – Business – dataadgang) hører dette mapping til i dataadgangslaget eller i et Repository. Vigtigt er, at Dataset ikke „durchgereicht“ wird: Objekte/DTOs er det mere stabile interface, især hvis man senere eftermonterer REST-APIs eller udlægger dele til C# Services.
Til drift og support er Debug-Hook’en OnDebug nyttig. Den lader dig i tests eller ved reproducerbare supporttilfælde logge, hvilke felter der rent faktisk blev mappet. I produktive systemer bør det være målrettet og kunne deaktiveres, ellers bliver logging for dyrt eller for dataintensivt.
Fornuftig brug af Debug-Hook
- Unit-Tests: Kontrollér, om en given SQL-forespørgsel virkelig leverer alle Required-Felder.
- Diagnose: Ved kundeproblemer ser man straks «feltet fandtes ikke» vs. «værdien kunne ikke konverteres».
- Migrationsphasen: Ved omstilling af views/kolonnenavne kan I vedligeholde kandidatlister parallelt, indtil alt er flyttet.
Hvornår denne tilgang når sine grænser (og hvad der så er bedre)
Det viste dataset-til-objekt-mapping er robust, når datakilden er ustabil, og man alligevel har brug for deterministisk adfærd. Det svigter typisk i to situationer:
- Meget store mængder (f.eks. masseeksport): Variant-konvertering og søgning per feltnavn kan blive mærkbar. Så er et forudberegnet feltindeks-cache per SQL værd at overveje (f.eks.
FieldByNameén gang per Dataset, ikke per Row). - Et meget stort antal DTO-typer: Hvis man skriver hundreder af mapper, bliver boilerplate et problem. Så kan en RTTI-baseret tilgang med attributter være fornuftig – men kun, hvis man stramt kontrollerer debug-udskrifter og konvertere.
Et godt kompromis er: feltopløsning og konvertering som her (eksplicit, fejltolerant hvor nødvendigt), men med genereret kode (f.eks. via interne templates) i stedet for „håndskrevet“.
Konklusion: Stabilitet gennem eksplicitte regler – med klare anvendelsesgrænser
Ved Legacy-Datasets med aliases, valgfrie kolonner og historisk Null-Semantik er Dataset-zu-Objekt Mapping især succesfuldt, når det forbliver eksplicit og diagnosebart. Planen for mapping bestående af kandidatlister, Required/Optional og konvertere skaber præcis det: I kan stabilisere altlasten trinvis, uden straks at indføre et ORM eller normalisere databasen „på én gang“.
Grænserne ligger ved ekstrem ydeevne og ved et meget stort antal typer – så har man brug for caching eller automatiseret kodegenerering. For typisk forretningssoftware med etablerede processer er tilgangen dog et pålideligt redskab til at adskille dataadgang og domænemodeller og gøre dem vedligeholdelsesvenlige igen.
Hvis I ved et konkret Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) har brug for en second opinion eller en belastbar målarkitektur, er næste skridt som regel en kort analyse med reproducerbare eksempler. Kontakt:
I det faglige miljø spiller også Delphi Dataset Mapping og Legacy Delphi en vigtig rolle, når integrationer, dataflow og videreudvikling skal fungere sammen konsistent.