Kod razvijenih Delphi-sustava mapiranje iz Dataseta u objekt rijetko je čist „jedno polje = jedna property“ slučaj. U individualnom poslovnom softveru umjesto toga naiđete na alias-stupce iz view‑ova, rezultate joinova s dupliciranim imenima polja, „prazne“ vrijednosti predstavljene kao 0 ili ' ', tipizirana polja koja danas vraćaju VARCHAR, a sutra INTEGER, i stupce koji, ovisno o dijalogu pretraživanja, jednostavno nisu prisutni. Upravo tu mnogi mapperi zakažu: ili postanu previše „magični“ (i time teško debuggabilni), ili su toliko strogi da već jedno opcionalno polje zaustavi rad.
Ovaj izvadak koda prikazuje pragmatičan mapper za Delphi, koji svjesno nije ORM, ali uredno rješava najvažnije legacy rubne slučajeve: jednoznačno razrješavanje polja, kontroliranu konverziju, null‑semantiku, opcionalna polja i razumljive poruke o pogrešci. Namijenjen je za Data‑Access‑Layer (DAL, dakle sloj koji kapsulira pristup podacima) ili Repository‑patterne – i dobro se kombinira s BDE-Ablosung mit nativer Anbindung (Delphis biblioteka za pristup podacima za mnoge DB‑ove).
Zašto standardno mapiranje ne uspijeva kod naslijeđenih struktura
Par tipičnih uzroka iz produkcije koje se pri „urednom“ redizajnu rijetko vide:
- Dvosmisleni nazivi polja: Join vraća
IDiz više tablica; u Datasetu se onda nazivaID,ID_1ili je preimenovano SQL‑aliasom. - Semantičke nule:
0znači „nepoznato“,'1899-12-30'je „nije datum“,' 'znači „nije uneseno“. - Promjenjivi tipovi: View ne radi cast; drajver vraća
ftWideStringumjestoftInteger. Variant‑konverzija postaje izvor pogrešaka. - Opcionalni stupci: Dijalog pretrage ovisno o filteru koristi različite SELECT‑liste. Kod, međutim, očekuje polja „uvijek“.
- Mogućnost debugiranja: Ako se mapiranje izgubi u RTTI‑ju, pronalaženje pogrešaka u korisničkim podacima postaje teško (koje polje, koja vrijednost, koji tip?).
Pristup: Mapping‑Plan umjesto konvencije, s kontroliranom konverzijom
Srž je u Mapping‑Planu: lista pravila „Property X dolazi iz polja A ili B, je opcionalno/obavezno, koristi konverter Y“. Time mapiranje ostaje deklarativno, ali ne „nevidljivo“ kao kod mnogih ORM‑mehanizama. Mapper može za svako polje baciti smislenu iznimku s kontekstom, uključujući ime polja, tip podatka i sirovu vrijednost.
Važno: svjesno mapiramo iz TDataSet, a ne iz konkretne BDE-Ablosung mit nativer Anbindung‑klase. Time ostaje kompatibilno s TFDQuery, TClientDataSet ili vanjskim komponentama.
Izvadak koda: Debugabilno Dataset‑za‑Objekt mapiranje za legacy stupce
Kod implementira:
- Razrješavanje polja preko liste prioriteta (aliasi/fallbackovi)
- Rukovanje obavezno/opcionalnim poljima
- Null‑semantiku preko konvertera (npr.
0 => Null) - Stabilne poruke o pogrešci s kontekstom
- Debug‑hook koji omogućuje praćenje problema mapiranja u testu ili u slučaju podrške
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 prima Variant i vraća Variant (npr. Null, Integer, String, TDateTime kao 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: poziva setter za svaki Spec. Nema RTTI: eksplicitna dodjela je lakše za debugiranje.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Pomoćni konverteri
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 umjesto FieldByName: omogućuje opcionalno ponašanje bez iznimke
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 nije aktivan.‘);
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(‚Pogreška mapiranja: obavezno polje za %s nije pronađeno. Kandidati: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcionalno: jednostavno preskočiti
end;
Raw := F.Value; // Variant; uzima u obzir 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 nakon konverzije je greška (češće nego što se misli)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Pogreška mapiranja: %s je obavezno, ali vrijednost je NULL nakon konverzije. Polje %s (%s), sirova vrijednost=%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(‚Pogreška mapiranja kod %s iz polja %s (%s), sirova vrijednost=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konverteri }
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);
// tolerira i ‚0‘ kao 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);
// Svjesno strogo: nema ‚Try‘ koji bi skrivao probleme s kvalitetom podataka.
// Format može varirati ovisno o legacy sustavu; po potrebi parametrizirati ovdje preko TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Kako praktično koristiti mapper (bez RTTI, ali ipak elegantno)
Mapper poziva callback-funkciju Assign(TargetMember, Value). To čini dodjelu eksplicitnom (i time dobro za debug) i izbjegava RTTI-pristupe u Hot-Pathu. U praksi za svaki objekt/DTO (Data Transfer Object, dakle transportni objekt za podatke) izradite mali „dodjeljivač“.
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;Svrha: Mapiranje je centralno opisano na jednom mjestu (Specs), ali dodjela ostaje eksplicitna. U Legacy-situacijama to je obično povoljniji kompromis od potpuno automatskog RTTI-mapiranja, jer odmah vidite koje svojstvo ovisi o kojem imenu polja.
Uvjeti: Pristup pretpostavlja aktivan Dataset i trenutnu poziciju zapisa. Za batch-importe iterirajte izvana pomoću while not DS.Eof do i pozivajte MapCustomer za svaki red.
Zamke: Pazite na VarToStr kod BLOB-ova ili memo-polja; za njih biste trebali koristiti vlastite konvertere. I: „Required“ znači ovdje nakon konvertera. Ako C_TrimToNull required-polje postavi na Null, to je namjerno – kvaliteta podataka tada se mora razjasniti u izvoru ili u procesu.
Varijante: Umjesto string-targeta možete koristiti i enum kako biste isključili pogreške pri tipkanju. Alternativno, Assign-funkciju po specu možete spremiti kao TProc<Variant>, čime se Target-string u potpunosti uklanja (malo više boilerplate-a, ali još manje mogućnosti pogreške).
Položaj u arhitekturi: DAL/Repository, logiranje i operativni rad
U arhitekturi slojeva (tipično: UI – Business – sloj pristupa podacima) ovo mapiranje pripada sloju pristupa podacima ili repository-ju. Važno je da se Dataset ne prosljeđuje dalje: objekti/DTO-i su stabilnije sučelje, posebno ako kasnije dodajete REST-APIs ili izlažete dijelove kao C# Servisi.
Za rad i podršku isplati se Debug-Hook OnDebug. Njime možete u testovima ili kod reproducibilnih slučajeva podrške zabilježiti koja su polja zapravo mapirana. U produktivnim sustavima to bi trebalo biti selektivno i mogućnost isključivanja, inače će logiranje postati preskupo ili previše opterećujuće za pohranu podataka.
Smisleno korištenje Debug-Hooka
- Jedinični testovi: Provjerite daje li određeni SQL-upit zaista sva obvezna polja.
- Dijagnostika: Kod problema kod klijenata odmah vidite „polje nije bilo prisutno“ nasuprot „vrijednost se nije mogla konvertirati“.
- Faze migracije: Pri promjeni Views/imen stupaca možete paralelno održavati liste kandidata dok sve nije premješteno.
Kada ovaj pristup zakaže (i što je tada bolje)
Prikazano mapiranje dataset-a u objekt snažno je rješenje kad je izvor podataka nestabilan, a i dalje trebate determinističko ponašanje. Tipično posustaje u dvije situacije:
- Vrlo velike količine (npr. masovni izvoz): Variant-konverzija i pretraživanje po imenu polja može postati mjerljivo opterećenje. Tada se isplati unaprijed izračunato caching indeksa polja po SQL-u (npr.
FieldByNamejednom po datasetu, ne po retku). - Vrlo mnogo DTO-tipova: Ako pišete stotine mappera, boilerplate postaje problem. Tada može biti smislen RTTI-baziran pristup s atributima – ali samo ako strogo kontrolirate debug-izlaze i konvertere.
Dobar kompromis je: rješavanje polja i konverzija kao ovdje (eksplicitno, tolerantno prema greškama gdje je potrebno), ali s generiranim kodom (npr. putem internih predložaka) umjesto „ručno pisanog“.
Zaključak: Stabilnost kroz eksplicitna pravila – s jasnim granicama primjene
Kod legacy-dataset-a s aliasima, opcionalnim stupcima i povijesnom null-semantikom mapiranje dataset-a u objekt posebno je uspješno ako ostane eksplicitno i dijagnostički upotrebljivo. Plan mapiranja sastavljen od listi kandidata, Required/Optional i konvertera upravo to omogućuje: možete postupno stabilizirati naslijeđene probleme bez uvođenja ORM-a ili trenutne normalizacije baze podataka.
Granice su u ekstremnoj izvedbi i pri vrlo velikom broju tipova – tada trebate caching ili automatiziranu generaciju koda. Za tipičan poslovni softver s dugogodišnjim procesima pristup je ipak pouzdana poluga da se pristup podacima i domenski modeli ponovno odvoje i postanu održivi.
Ako kod konkretnog legacy-mappinga (FireDAC, Views, divlji rast JOIN-ova, Null-semantika) trebate drugo mišljenje ili pouzdanu ciljnu arhitekturu, sljedeći korak obično je kratka analiza s reproducibilnim primjerima. Kontakt:
U stručnom kontekstu važnu ulogu imaju i Delphi Dataset Mapping i Legacy Delphi, kad integracije, tokovi podataka i daljnji razvoj moraju funkcionirati čisto zajedno.
Razgovarajte o projektu ili modernizacijskom pothvatu s Net-Base.