În sistemele Delphi existente, maparea din Dataset în obiect este rar cazul curat „un Feld = eine Property“. În software-ul individual pentru întreprinderi întâlniți în schimb coloane alias din view-uri, rezultate de join cu nume de câmp duplicate, valori „goale” reprezentate ca 0 sau ' ', câmpuri tipizate care azi returnează VARCHAR și mâine INTEGER, și coloane care, în funcție de dialogul de căutare, pur și simplu nu sunt prezente. Exact acolo eșuează mulți mapperi: fie devin prea „magici” (și astfel greu de depanat), fie sunt atât de stricți încât un câmp opțional oprește funcționarea.
Acest fragment de cod arată un mapper pragmatic pentru Delphi, care în mod conștient nu este un ORM, dar tratează curat cele mai importante cazuri-limită legacy: rezoluție unică a câmpurilor, conversie controlată, semantică pentru null, câmpuri opționale și mesaje de eroare care se pot urmări. Se potrivește pentru Data-Access-Layer (DAL, adică un strat care încapsulează accesul la date) sau pentru pattern-uri Repository – și se combină bine cu BDE-înlocuire cu conectare nativă (biblioteca de acces la date a Delphi pentru multe DB-uri).
De ce eșuează mapping-ul standard la structuri vechi
Câteva cauze tipice din exploatare, pe care le vedeți rar la un redesign „curat”:
- Nume de câmp ambigue: Join-ul returnează
IDdin mai multe tabele; în Dataset apare atunciID,ID_1sau este redenumit prin alias SQL. - Null-uri semantice:
0înseamnă „necunoscut”,'1899-12-30'este „nu este o dată”,' 'înseamnă „necompletat”. - Tipuri fluctuante: Un view nu face cast; driverul livrează
ftWideStringîn loc deftInteger. Conversia Variant devine sursă de erori. - Coloane opționale: Un dialog de căutare folosește, în funcție de filtru, liste SELECT diferite. Codul se așteaptă însă la câmpuri „întotdeauna”.
- Depanabilitate: Când mapping-ul dispare în RTTI, depanarea pe datele clientului devine dificilă (care câmp, ce valoare, ce tip?).
Abordare: Plan de mapping în loc de convenție, cu conversie controlată
Nucleul este un Mapping-Plan: o listă de reguli „Proprietatea X provine din câmpul A sau B, este optional/required, folosește convertorul Y”. Astfel mapping-ul rămâne declarativ, dar nu „invizibil” ca în multe mecanisme ORM. În plus, mapper-ul poate arunca pentru fiecare câmp o excepție informativă, incluzând numele câmpului, tipul de date și valoarea brută.
Important: Mapăm în mod deliberat din TDataSet, nu dintr-o clasă concretă BDE-Ablosung mit nativer Anbindung. Astfel rămâne compatibil cu TFDQuery, TClientDataSet sau cu componente terțe.
Fragment de cod: Mapare debugabilă din Dataset în obiect pentru coloane legacy
Codul implementează:
- Rezoluția câmpurilor printr-o listă de priorități (aliasuri / fallback-uri)
- Gestionarea câmpurilor obligatorii/opționale
- Semantica null prin convertoare (de ex.
0 => Null) - Mesaje de eroare stabile, cu context
- Un debug-hook pentru a putea urmări problemele de mapping în test sau în cazuri de suport
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);
// Convertor primește Variant și returnează Variant (de ex. Null, Integer, String, TDateTime ca 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: apelează setter pentru fiecare Spec. Fără RTTI: atribuirea explicită este mai ușor de depanat.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Convertori utilitari
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 în loc de FieldByName: posibil opțional, fără excepție
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 nu este activ.‘);
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(‚Eroare de mapare: câmpul obligatoriu pentru %s nu a fost găsit. Candidați: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opțional: pur și simplu sări peste
end;
Raw := F.Value; // Variant; ține cont de 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 după convertor este o eroare (mai frecvent decât s-ar crede)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Eroare de mapare: %s este obligatoriu, dar valoarea este NULL după conversie. Câmp %s (%s), valoare brută=%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(‚Eroare de mapare la %s din câmpul %s (%s), valoare brută=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Convertori }
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);
// tolerează și ‚0‘ ca 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);
// Intenționat strict: niciun „Try“ nu ascunde problemele de calitate a datelor.
// Formatul poate varia în funcție de sistemele legacy; eventual parametrizați aici prin TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Cum se folosește practic Mapper-ul (fără RTTI, dar în continuare elegant)
Mapper-ul apelează o funcție callback Assign(TargetMember, Value). Aceasta păstrează atribuirea explicită (și astfel ușor de depanat) și evită accesările RTTI pe calea critică. În practică construiți pentru fiecare obiect/DTO (Data Transfer Object, adică un obiect de transport pentru date) un mic „atribuitor”.
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;Scop: Mapping-ul este descris în mod centralizat într-un singur loc (Specs), dar atribuirea rămâne explicită. În situații Legacy aceasta este de multe ori decizia de compromis mai bună față de un RTTI-mapping complet automat, pentru că vedeți imediat de ce nume de câmp depinde fiecare proprietate.
Condiții prealabile: Abordarea presupune un Dataset activ și o poziție de înregistrare curentă. Pentru importuri în loturi iterați în exterior cu while not DS.Eof do și apelați MapCustomer pentru fiecare rând.
Capcane: Acordați atenție la VarToStr pentru BLOB-uri sau câmpuri Memo; acolo trebuie să folosiți convertoare proprii. Și: „Required” înseamnă aici după convertor. Dacă C_TrimToNull setează un câmp Required la null, este intenționat – calitatea datelor trebuie clarificată atunci la sursă sau în proces.
Variante: În loc de target-uri ca string puteți folosi și un enum pentru a elimina erorile de scriere. Alternativ, funcția Assign poate fi stocată per Spec ca TProc<Variant>, caz în care dispare complet string-ul Target (puțin mai mult boilerplate, dar și o clasă de erori redusă).
Încadrarea în arhitectură: DAL/Repository, Logging și operare
Într-o arhitectură pe straturi (tipic: UI – Business – acces la date) acest mapping aparține stratului de acces la date sau unui Repository. Important este ca Dataset-ul să nu fie „transferat” direct: obiectele/DTO-urile sunt interfața mai stabilă, mai ales dacă ulterior veți adăuga API-uri REST sau externaliza părți în C# Services.
Pentru operare și suport merită Debug-Hook-ul OnDebug. Puteți astfel, în teste sau în cazuri de suport reproductibile, să înregistrați care câmpuri au fost efectiv mapate. În sisteme de producție ar trebui să fie utilizat țintit și să poată fi dezactivat, altfel logging-ul devine prea costisitor sau generează prea multe date.
Utilizarea practică a Debug-Hook-ului
- Testele unitare: Verificați dacă o anumită interogare SQL livrează într-adevăr toate câmpurile Required.
- Diagnoză: În cazuri de probleme la client vedeți imediat „câmpul nu exista“ vs. „valoarea nu a putut fi convertită“.
- Faze de migrare: La schimbarea de Views/nume de coloane puteți menține liste de candidați în paralel până când totul este migrat.
Când această abordare nu mai funcționează (și ce e mai bine atunci)
Maparea dataset‑la‑obiect prezentată este robustă când sursa de date este instabilă și aveți totuși nevoie de un comportament determinist. De obicei devine problematică în două situații:
- Volume foarte mari (de ex. export în masă): conversia Variant și căutarea după numele câmpului pot deveni perceptibile. Atunci merită un cache pregătit al indexului de câmp per SQL (de ex.
FieldByNameo singură dată per Dataset, nu per rând). - Un foarte mare număr de tipuri DTO: Dacă scrieți sute de mapper-e, apare mult cod boilerplate. Atunci poate fi utilă o abordare bazată pe RTTI cu atribute – dar doar dacă controlați strict ieșirile de debug și convertoarele.
O cale de mijloc bună este: rezoluția câmpurilor și conversia ca aici (explicit, tolerant la erori acolo unde e necesar), dar cu cod generat (de ex. prin template-uri interne) în loc de „scris manual“.
Concluzie: Stabilitate prin reguli explicite – cu limite clare de utilizare
În cazul dataset-urilor legacy cu aliasuri, coloane opționale și semantică istorică a valorii NULL, maparea dataset‑la‑obiect are succes în special atunci când rămâne explicită și diagnosticabilă. Planul de mapping format din liste de candidați, Required/Optional și convertoare realizează exact acest lucru: puteți stabiliza treptat resturile istorice fără a introduce imediat un ORM sau a normaliza baza de date „dintr-o dată“.
Limitele apar la performanță extremă și la un număr foarte mare de tipuri – atunci aveți nevoie de caching sau generare automată de cod. Pentru software business tipic, cu procese evoluate, abordarea rămâne însă o pârghie fiabilă pentru a decupla accesul la date de modelele de domeniu și pentru a le menține ușor de întreținut.
Dacă pentru un mapping legacy concret (FireDAC, Views, proliferare de join-uri, semantică NULL) aveți nevoie de o a doua opinie sau de o arhitectură țintă solidă, pasul următor este de regulă o analiză scurtă cu exemple reproductibile. Kontakt:
În sfera funcțională, și Delphi Dataset Mapping și Legacy Delphi joacă un rol important atunci când integrațiile, fluxurile de date și evoluția trebuie să colaboreze coerent.
Discutați un proiect sau o inițiativă de modernizare cu Net-Base.