I vaksne Delphi-system er dataset-til-objekt-mapping sjeldan det reine «eit felt = ei property»-tilfellet. I skreddarsydd bedriftsprogramvare møter ein i staden alias-kolonnar frå Views, join-resultat med doble feltnamn, „tomme“ verdiar som 0 eller ' ', typa felt som i dag leverer VARCHAR og i morgon INTEGER, og kolonnar som avhengig av søkdialogen rett og slett ikkje er med. Det er nettopp her mange mapperar sviktar: Anten blir dei for «magiske» (og dermed vanskelege å feilsøke), eller så strenge at eit valfritt felt stoppar drifta.
Denne kodesnutten viser ein pragmatisk mapper for Delphi, som medvite ikkje er eit ORM, men som reint adresserer dei viktigaste legacy-kanttilfella: entydig feltoppløysing, kontrollert konvertering, null-semantikk, valfrie felt og etterprøvbare feilmeldingar. Han eignar seg for Data-Access-Layer (DAL, altså eit lag som kapslar datatilgang) eller repository-patterns – og kan godt kombinerast med BDE-erstatting med nativ tilkopling (Delphis datatilgangsbibliotek for mange DB-ar).
Kvifor standard-mapping feilar på eldre strukturar
Nokre typiske årsaker frå drifta som ein sjeldan ser ved «reint» nydesign:
- Tvetydelege feltnamn: Join leverer
IDfrå fleire tabellar; i datasetet heiter det dåID,ID_1eller er omdøypt via SQL-alias. - Semantiske nullar:
0tyder «ukjend»,'1899-12-30'er «ikkje ein dato»,' 'er «ikkje vedlikehalde». - Svingande typar: Ein View kastar ikkje; driveren leverer
ftWideStringi staden forftInteger. Variant-konvertering blir ein feilkjelde. - Valfrie kolonnar: Ein søkdialog brukar avhengig av filter ulike SELECT-lister. Koden ventar likevel felt «alltid».
- Debuggbarheit: Når mapping forsvinn inn i RTTI, blir feilsøking mot kundedata vanskeleg (kva for felt, kva verdi, kva type?).
Tilnærming: Mapping-plan i staden for konvensjon, med kontrollert konvertering
Kjernen er ein mapping-plan: ei liste med reglar «Property X kjem frå felt A eller B, er valfri/obligatorisk, nyttar konverter Y». Slik held mappinget seg deklarativt, men ikkje «usynleg» som ved mange ORM-mekanismar. I tillegg kan mapperen per felt kaste eit tydeleg unntak, inkludert feltnamn, datatype og råverdi.
Viktig: Vi mapper medvite frå TDataSet, ikkje frå ei konkret BDE-Ablosung mit nativer Anbindung-klasse. Slik held det seg kompatibelt med TFDQuery, TClientDataSet eller også tredjepartskomponentar.
Kodesnutt: Debuggbart dataset-til-objekt-mapping for legacy-kolonnar
Koden implementerer:
- Feltoppløysing via ein prioriteringsliste (Aliases/Fallbacks)
- Handtering av påkrevde/valfrie felt
- Null-semantikk via konverterar (t.d.
0 => Null) - Stabile feilmeldingar med kontekst
- Eit debug-hook for å kunne etterprøve mapping-problem i test eller ved support
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 tar Variant og returnerer Variant (t.d. Null, Integer, String, TDateTime som 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: kallar setter for kvar Spec. Ikkje RTTI: eksplisitt tilordning er lettare å feilsøkje.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Hjelpekonverterar
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 i staden for FieldByName: mogleg utan unntak
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('Datasetet er ikkje 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-feil: Påkrevd felt for %s ikkje funne. Kandidatar: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valfritt: berre hopp over
end;
Raw := F.Value; // Variant; tek omsyn til 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 etter konvertering er ein feil (vanlegare enn ein trur)
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-feil: %s er påkrevd, men verdi er NULL etter konvertering. Felt %s (%s), råverdi=%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-feil ved %s frå felt %s (%s), råverdi=%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);
// Med vilje strikt: ingen "Try" som slukkar datakvaliteten.
// Formatet kan variere avhengig av legacy; eventuelt parametriser her via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Korleis ein brukar Mapperen i praksis (utan RTTI, men framleis elegant)
Mapperen kallar ein Assign(TargetMember, Value)-callback-funksjon. Det held tilordninga eksplisitt (og dermed godt feilsøkbar) og unngår RTTI-tilgang i hot-pathen. I praksis byggjer ein for kvart objekt/DTO (Data Transfer Object, altså eit transportobjekt for data) ein liten «tilordnar».
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: Mappinga er beskriven samla (Specs), men tilordninga forblir eksplisitt. I legacy-situasjonar er dette som regel eit betre kompromiss enn eit fullautomatisk RTTI-mapping, fordi ein med ein gong ser kva Property som er avhengig av kva feltnamn.
Forutsetningar: Tilnærminga forutset eit aktivt Dataset og ein aktuell rekordposisjon. For batch-importar itererer ein utanfor over while not DS.Eof do og kallar MapCustomer per rad.
Fallgruver: Ver merksam på VarToStr ved BLOB-ar eller memo-felt; der bør ein bruke eigne konverterar. Og: „Required“ betyr her etter konverteren. Om C_TrimToNull set eit Required-felt til Null, er det meint slik – datakvaliteten må då avklarast ved kjelda eller i prosessen.
Variantar: I staden for String-Targets kan ein også bruke eit enum for å unngå skrivefeil. Alternativt kan Assign-funksjonen per Spec lagrast som TProc<Variant>, då fell Target-strengen heilt bort (litt meir boilerplate, men færre feiltypar).
Plassering i arkitekturen: DAL/Repository, Logging og drift
I ein lagdelt arkitektur (typisk: UI – Business – dataåtkomst) høyrer dette mappinget til i dataåtkomstlaget eller i eit Repository. Det er viktig at datasettet ikkje blir vidaregjeve: Objekt/DTO-ar er eit meir stabilt grensesnitt, særleg når ein seinare ettermonterer REST-APIar eller flyttar ut delar til C# tenester.
For drift og support løner Debug-Hooken OnDebug seg. Ein kan med han i testar eller ved reproducerbare supporttilfelle loggføre kva felt som faktisk vart mappa. I produksjonssystem bør dette vere målretta og mogleg å slå av, elles blir logging for dyrt eller for dataintensivt.
Fornuftig bruk av Debug-Hook
- Unit-Tests: Sjekke om eit bestemt SQL-Statement verkeleg leverer alle Required-Felder.
- Diagnose: Ved kundespørsmål ser ein med ein gong «feltet fanst ikkje» vs. «verdi kunne ikkje konverterast».
- Migrasjonsphaser: Ved omlegging av Views/kolonnenamn kan ein halde kandidatlister parallelt til alt er flytta.
Når denne tilnærminga sviktar (og kva som då er betre)
Det viste Dataset-til-objekt Mappinget er robust når datakjelda er ustabil og ein likevel treng deterministisk åtferd. Det sviktar vanlegvis i to situasjonar:
- Svært store mengder (t.d. masseeksport): Variant-konvertering og å søkje per feltnamn kan bli merkbart. Då løner eit førehandskalkulert feltindeks-caching per SQL seg (t.d.
FieldByNameein gong per Dataset, ikkje per Row). - Svært mange DTO-Typen: Når ein skriv hundrevis av Mapper, blir boilerplate eit tema. Då kan ein RTTI-basert tilnærming med attributt vere hensiktsmessig – men berre viss ein strikt kontrollerer debug-utgåver og konverterarane.
Eit godt mellomveg er: Feltoppløysing og konvertering som her (eksplisitt, feiltolerant der nødvendig), men med generert kode (t.d. gjennom interne malar) i staden for «handskriven».
Konklusjon: Stabilitet gjennom eksplisitte reglar – med klare bruksgrenser
Ved Legacy-Datasets med Aliases, valfrie kolonnar og historisk Null-Semantik lykkast Dataset-til-objekt Mapping først og fremst når det held seg eksplisitt og diagnosemogleg. Planen for mapping med kandidatlister, Required/Optional og konverterarar gir nett dette: Ein kan stabilisere arv trinnvis, utan å innføre eit ORM eller normalisere databasen «på ein gong».
Grensene går ved ekstrem ytelse og ved svært mange typar – då treng ein Caching eller automatisk kodeframstilling. For typisk Business-Software med vaksne prosessar er tilnærminga likevel eit påliteleg verkemiddel for å få datatilgang og Domänenmodelle att skilde og vedlikehaldne.
Dersom du i eit konkret Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) treng ei annan vurdering eller ei påliteleg målarkitektur, er neste steg vanlegvis ei kort analyse med reproduserbare døme. Kontakt:
I det faglege miljøet spelar også Delphi Dataset Mapping og Legacy Delphi ei viktig rolle, når integrasjonar, dataflyter og vidareutvikling må spele godt saman.