Az idővel kialakult Delphi-rendszerekben a Dataset-objektum leképezés ritkán az a tiszta „egy mező = egy property” eset. Egyedi vállalati szoftverekben ehelyett alias-oszlopokkal a view-król, JOIN-eredményekkel, duplikált mezőnevekkel, „üres” értékekkel mint 0 vagy ' ', típusosan változó mezőkkel, amelyek ma VARCHAR, holnap pedig INTEGER adnak, illetve olyan oszlopokkal találkozik az ember, amelyek a keresődialogtól függően egyszerűen kimaradnak. Pont itt buknak meg sok mapper: vagy túl „varázslatossá” válnak (és ezáltal nehezen hibakereshetők), vagy olyan szigorúak, hogy egy opcionális mező már leállítja az üzemet.
Ez a forrásrészlet egy pragmatikus mapper-t mutat be Delphi-hoz, amely szándékosan nem ORM, de a legfontosabb örökségi szélső eseteket tisztán kezeli: egyértelmű mezőfeloldás, kontrollált konverzió, null-szemantika, opcionális mezők és nyomon követhető hibajelzések. Alkalmas Data-Access-Layer (DAL, azaz az adatelérést kapszulázó réteg) vagy Repository-patterns számára – és jól kombinálható a BDE-kiváltással natív csatlakozással (Delphi-hoz tartozó adatelérési könyvtár több adatbázishoz).
Miért bukik el a szabványos leképezés örökségi struktúrák esetén
Néhány tipikus üzemeltetési ok, amelyeket egy „tiszta” újratervezésnél ritkán látni:
- Többértelmű mezőnevek: a JOIN több táblából adja az
ID-t; a Datasetben ezutánID,ID_1lesz, vagy SQL-alias-szal átnevezik. - Szemantikus nullák: a
0„ismeretlen”-t jelent, a'1899-12-30'„nem dátum”, a' 'pedig „nem karbantartott”. - Változó típusok: a view nem castol; a driver
ftWideString-et adftIntegerhelyett. A Variant-konverzió hibaforrássá válik. - Opcionális oszlopok: egy keresődialog szűrőtől függően más SELECT-listákat használ. A kód viszont azt várja, hogy a mezők „mindig” jelen vannak.
- Hibakereshetőség: ha a mapping az RTTI-ba tűnik el, a vevői adatok hibakeresése nehéz (melyik mező, melyik érték, melyik típus?).
Megközelítés: Mapping-terv a konvenció helyett, kontrollált konverzióval
A lényeg egy Mapping-terv: egy szabálylista, „X property a(z) A vagy B mezőből jön, opcionális/kötelező, a Y konvertert használja”. Így a leképezés deklaratív marad, de nem „láthatatlan”, mint sok ORM-mechanizmusnál. Emellett a mapper mezőnként kifejező kivételt dobhat, tartalmazva a mezőnevet, adattípust és a nyers értéket.
Fontos: szándékosan TDataSet-ből leképezünk, nem egy konkrét BDE-Ablosung mit nativer Anbindung-osztályból. Így kompatibilis marad TFDQuery-vel, TClientDataSet-tel vagy külső komponensekkel.
Forrásrészlet: Hibakereshető Dataset-objektum leképezés örökségi oszlopokhoz
A kód a következőket valósítja meg:
- Mezőfeloldás prioritási lista alapján (aliasok / fallback-ek)
- Kötelező / opcionális mezők kezelése
- Null-szemantika konvertereken keresztül (pl.
0 => Null) - Stabil hibajelzések kontextussal
- Egy debug-hook, amely lehetővé teszi a mapping-problémák teszt vagy support esetben történő nyomon követését
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);
// A konverter Variant-et kap és Variant-et ad vissza (pl. NULL, Integer, String, TDateTime mint 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: meghívja a settert minden Spec-re. Nincs RTTI: az explicit hozzárendelés jobban debuggolható.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Segédkonverterek
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 statt FieldByName: optional möglich, ohne 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(‚A dataset nem aktív.‘);
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-hiba: Kötelező mező %s nem található. Jelöltek: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcionális: egyszerűen kihagyjuk
end;
Raw := F.Value; // Variant; figyelembe veszi a NULL-t
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;
// Kötelező: NULL a konvertálás után hiba (gyakoribb, mint gondolnánk)
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-hiba: %s kötelező, de az érték NULL a konvertálás után. Mező %s (%s), nyersérték=%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-hiba %s esetén a %s mezőből (%s), nyersérték=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konverterek }
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);
// elfogadja a ‚0‘-t is stringként
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);
// Szándékosan szigorú: nincs ‚Try‘, amely elnyelné az adatminőség problémáit.
// A formátum a legacy rendszertől függően változhat; szükség esetén itt parametrizálható TFormatSettings-szel.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Hogyan használjuk a mappert gyakorlatban (RTTI nélkül, mégis elegánsan)
A mapper egy Assign(TargetMember, Value)-callback-függvényt hív meg. Ez explicitté teszi a hozzárendelést (és így jól debuggolhatóvá), továbbá elkerüli az RTTI-hívásokat a hot-path-en. A gyakorlatban minden objektum/DTO (Data Transfer Object, azaz adatszállító objektum) mellé építse fel a kis „hozzárendelőt”.
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;Cél: A mapping központilag, egy helyen van leírva (Specs), ugyanakkor a hozzárendelés explicitt marad. Örökölt rendszerek esetén ez gyakran jobb kompromisszum, mint egy teljesen automatikus RTTI-mapping, mert azonnal látszik, melyik property mely mezőnevektől függ.
Feltételek: A megközelítés aktív Dataset-et és aktuális rekordpozíciót vár. Batch-importok esetén iteráljon kívülről while not DS.Eof do-val, és hívja meg soronként a MapCustomer-t.
Bukkanó kövek: Figyeljen a VarToStr-re BLOB- vagy memo-mezőknél; ott saját konvertert célszerű használni. És: a „Required” itt a konverter után értendő. Ha a C_TrimToNull egy required mezőt nullra alakít, az szándékos – az adatok minőségét ilyenkor a forrásnál vagy a folyamatban kell rendezni.
Változatok: String-targetek helyett használhat Enum-ot a gépelési hibák kizárására. Alternatívaként az Assign-függvényt spec-enként TProc<Variant>-ként is tárolhatja, ekkor a Target-string teljesen elhagyható (kisebb boilerplate ára mellett még kevesebb hiba lehetőség).
Elhelyezés az architektúrában: DAL/Repository, naplózás és üzemeltetés
Egy rétegzett architektúrában (tipikus: UI – Business – adat-hozzáférés) ez a mapping az adat-hozzáférési rétegbe vagy egy repository-ba tartozik. Fontos, hogy a Dataset ne legyen „átadva” tovább: az objektumok/DTO-k a stabilabb interfész, különösen ha később REST-API-kat utólagosan ad be, vagy részeket C# szolgáltatások formájában kiszervez.
Az üzemeltetés és a támogatás szempontjából megéri a Debug-Hook OnDebug. Ezzel tesztekben vagy reprodukálható support-eseteknél naplózhatja, mely mezők kerültek ténylegesen leképezésre. Termelő rendszerekben ezt célszerű célzottan alkalmazni és kikapcsolhatóvá tenni, különben a naplózás túl költségessé vagy túl adatgazdaggá válik.
Debug-Hook ésszerű használata
- Unit-Tests: Ellenőrizze, hogy egy adott SQL-utasítás valóban minden Required-Feld liefert.
- Diagnose: Ügyfélproblémák esetén azonnal látható, hogy „mező nem volt jelen” vagy „értéket nem lehetett konvertálni”.
- Migrationsphasen: Views/Spaltennamen átállításakor párhuzamosan tarthat fenn jelöltlistákat, amíg minden át nem költözik.
Mikor válik ez a megközelítés problémássá (és mi ilyenkor jobb)
A bemutatott Dataset-zu-Objekt leképezés erős megoldás, ha az adatforrás ingadozó, és mégis determinisztikus viselkedésre van szükség. Tipikusan két helyzetben szokott megbukni:
- Nagyon nagy mennyiségek (pl. tömeges export): Variant-konverzió és mezőnév szerinti keresés érezhetővé válhat. Ilyenkor érdemes előre kiszámított mezőindex-cachinget alkalmazni SQL szinten (pl.
FieldByNameegyszer per Dataset, nem soronként). - Nagyon sok DTO-típus: Ha százával írja a Mapper-eket, a boilerplate válik problémássá. Ilyenkor egy RTTI-alapú megközelítés attribútumokkal célravezető lehet – de csak akkor, ha a debug-kimeneteket és a konvertereket szigorúan kontrollálják.
Jó középút: a mezőfeloldás és konvertálás, ahogy itt is (explicit, hibatűrő ahol szükséges), de generált kóddal (pl. belső sablonok alapján) a „kézzel írt” helyett.
Következtetés: Stabilitás explicite szabályokkal – világos alkalmazási határokkal
Legacy-Datasets esetén, ahol aliasok, opcionális oszlopok és történeti nulla-szemantika fordulnak elő, a Dataset-zu-Objekt leképezés különösen akkor sikeres, ha az explicitté és diagnosztizálhatóvá válik. A jelöltlistákból, Required/Optional jelölésből és konverterekből álló mapping-terv pontosan ezt biztosítja: a régi terheket lépésről lépésre stabilizálhatja, anélkül hogy azonnal egy ORM-et vezetne be vagy az adatbázist „egyszerre” normalizálná.
A korlátok az extrém teljesítményigényeknél és nagyon sok típusnál jelentkeznek – ilyenkor cachingre vagy automatizált kódgenerálásra van szükség. Tipikus, növekvő folyamatokkal rendelkező üzleti szoftverek esetén azonban a megközelítés megbízható eszköz arra, hogy az adat-hozzáférést és a doménmodelleket ismét elkülönítve és karbantarthatóan tartsa.
Ha egy konkrét Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) kapcsán második véleményre vagy egy megalapozott célarchitektúrára van szüksége, a következő lépés általában egy rövid elemzés reprodukálható példákkal. Kapcsolat:
A szakmai környezetben a Delphi Dataset Mapping és a Legacy Delphi is fontos szerepet játszanak, ha az integrációk, az adatok áramlása és a továbbfejlesztés tisztán kell, hogy együttműködjenek.