Pri razvitih Delphi-sistemih je mapiranje iz DataSet v objekt redko čisti primer „eno polje = ena lastnost“. V prilagojeni poslovni programski opremi boste namesto tega naleteli na alias-stolpce iz view‑ov, rezultate join‑ov z dvojnimi imeni polj, „prazne“ vrednosti kot 0 ali ' ', tipizirana polja, ki danes vračajo VARCHAR in jutri INTEGER, ter stolpce, ki glede na iskalni dialog preprosto niso prisotni. Prav tam mnogi mapperji odpovejo: bodisi postanejo preveč „magični“ (in zato težje za razhroščevanje), bodisi so tako strogi, da že eno opcijsko polje ustavi delovanje.
Ta izsek izvorne kode prikazuje pragmatičen mapper za Delphi, ki zavestno ni ORM, vendar čisto naslavlja najpomembnejše legacy robne primere: enoznačna razrešitev polj, kontrolirane konverzije, semantiko ničelnih vrednosti, opcijska polja in razumljiva sporočila o napakah. Primeren je za Data-Access-Layer (DAL, torej plast, ki zavije dostop do podatkov) ali za Repository‑pattern – in se dobro kombinira z BDE-zamenjavo z nativno povezavo (knjižnica za dostop do podatkov Delphi za številne DB).
Zakaj standardno mapiranje pri starih strukturah spodleti
Nekaj tipičnih vzrokov iz obratovanja, ki jih pri „čistem“ novem načrtovanju redko vidite:
- Dvosmiselna imena polj: Join vrne
IDiz več tabel; v DataSetu se to pojavi kotID,ID_1ali je preimenovano z SQL‑aliasom. - Semantične ničelne vrednosti:
0pomeni „neznano“,'1899-12-30'je „ni datuma“,' 'je „ni navedeno“. - Nihajoči tipi: View ne izvaja pretvorbe tipov; gonilnik vrne
ftWideStringnamestoftInteger. Variant‑konverzija postane vir napak. - Opcijski stolpci: Iskalni dialog glede na filter uporablja različne SELECT‑liste. Koda pa pričakuje polja „vedno“.
- Možnost razhroščevanja: Če mapiranje izgine v RTTI, je iskanje napak pri podatkih naročnikov oteženo (katero polje, katera vrednost, kateri tip?).
Pristop: načrt mapiranja namesto konvencije, s kontrolirano konverzijo
Jezgro je načrt mapiranja: seznam pravil „Property X prihaja iz polja A ali B, je opcijsko/obvezno, uporablja konverter Y“. Tako ostane mapiranje deklarativno, a ne „nevidno“ kot pri mnogih ORM‑mehanizmih. Poleg tega lahko mapper za vsako polje vrže informativno izjemo, vključno z imenom polja, podatkovnim tipom in surovo vrednostjo.
Pomembno: namerno mapiramo iz TDataSet, ne iz konkretne klase BDE-Ablosung mit nativer Anbindung. Tako ostane združljivo z TFDQuery, TClientDataSet ali tudi s tujimi komponentami.
Izsek kode: razhroščljivo mapiranje iz DataSet v objekt za legacy stolpce
Koda implementira:
- Razrešitev polj preko seznama prioritet (aliasi/nadomestki)
- Obravnavo obveznih/izbirnih polj
- Semantiko ničel preko konverterjev (npr.
0 => Null) - Stabilna sporočila o napakah s kontekstom
- Debug‑hook, ki omogoča reproduciranje težav z mapiranjem v testu ali v primeru podpore
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);
// Pretvornik prejme Variant in vrne Variant (npr. Null, Integer, String, TDateTime kot 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: pokliče setter za vsak Spec. Brez RTTI: eksplicitna dodelitev je lažje za razhroščevanje.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Pretvorniki
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 namesto FieldByName: omogoča opcijsko iskanje brez izjeme
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 ni aktiven.');
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('Napaka preslikave: zahtevanega polja za %s ni bilo najdeno. Kandidati: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcijsko: preprosto preskoči
end;
Raw := F.Value; // Variant; upošteva 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 po pretvorbi je napaka (pogosteje, kot se zdi)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Napaka preslikave: %s je zahtevan, vendar je vrednost NULL po pretvorbi. Polje %s (%s), surova vrednost=%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('Napaka preslikave pri %s iz polja %s (%s), surova vrednost=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Pretvorniki }
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);
// sprejema tudi '0' kot niz
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);
// Namen strogo: noben 'Try' ne prikrije kakovosti podatkov.
// Oblika se lahko razlikuje glede na legacy; po potrebi jo parametrizirajte tukaj preko TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Kako praktično uporabljati Mapper (brez RTTI, a vseeno elegantno)
Mapper kliče Assign(TargetMember, Value)-callback-funkcijo. To ohranja dodeljevanje eksplicitno (in s tem lažje za razhroščevanje) ter izogne RTTI-dostopom v hot-pathu. V praksi za vsak objekt/DTO (Data Transfer Object, torej prenosni objekt za podatke) zgradite majhen „dodeljevalec“.
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;Namen: Mapiranje je na enem mestu centralno opisano (Specs), vendar dodeljevanje ostane eksplicitno. V Legacy-situacijah je to pogosto boljša kompromisna odločitev kot popolnoma avtomatsko RTTI-mapiranje, saj takoj vidite, katera property je odvisna od katerih imen polj.
Predpogoji: Pristop pričakuje aktivno Dataset in trenutno pozicijo zapisa. Za uvoze v serijah iterirajte zunaj z while not DS.Eof do in pokličite MapCustomer za vsako vrstico.
Pasti: Bodite pozorni na VarToStr pri BLOB-ih ali memo-poljih; tam je smiselno uporabiti lastne konverterje. In: „Required“ pomeni tukaj po konverterju. Če C_TrimToNull required-polje nastavi na Null, je to namerno – kakovost podatkov je treba rešiti pri viru ali v procesu.
Varianten: Namesto ciljnih nizov lahko uporabite tudi Enum, da izključite tipkarske napake. Alternativno lahko Assign-funkcijo shranite za vsak Spec kot TProc<Variant>, s čimer odpravite Target-string povsem (malo več boilerplata, vendar še manj razredov napak).
Umestitev v arhitekturo: DAL/Repository, logging in obratovanje
V plastični arhitekturi (tipično: UI – Business – dostop do podatkov) to mapiranje sodi v plast za dostop do podatkov ali v repoziotorij. Pomembno je, da Dataset ni „posredovan naprej“: objekti/DTO-ji so stabilnejši vmesnik, zlasti če boste kasneje nadgrajevali REST-APIs ali izločali dele v C# Services.
Za obratovanje in podporo se izplača debug-hook OnDebug. Z njim lahko v testih ali pri reproducibilnih podpornih primerih protokolirate, katera polja so bila dejansko mapirana. V produktivnih sistemih naj bo to ciljno uporabljeno in izklopljivo, sicer bo beleženje predrago ali bo vsebovalo preveč podatkov.
Smiselna uporaba debug-hooka
- Enotski testi: Preverite, ali določen SQL-stavek resnično vrne vsa zahtevana polja.
- Diagnostika: Pri težavah pri strankah takoj vidite ‚polje ni bilo prisotno‘ ali ‚vrednosti ni bilo mogoče pretvoriti‘.
- Faze migracije: Pri prehodu na nove Views/ime stolpcev lahko vzporedno vzdržujete sezname kandidatov, dokler vse ni preseljeno.
Kdaj ta pristop zataji (in kaj je potem bolje)
Prikazano mapiranje datasetov v objekte je učinkovito, kadar je vir podatkov nestabilen in vseeno potrebujete deterministično vedenje. Običajno odpove v dveh situacijah:
- Zelo velike količine (npr. množični izvoz): pretvorbe Variant in iskanje po imenu polja lahko postanejo opazne. Takrat se izplača vnaprejšnje predpomnjenje indeksov polj za vsak SQL (npr.
FieldByNameenkrat na Dataset, ne za vsako vrstico). - Zelo veliko tipov DTO: Če napišete več sto mapperjev, bo ponavljajoča se koda težava. Takrat je lahko smiselna RTTI-podprt pristop z atributi – vendar le, če strogo nadzirate izpise za diagnostiko in konverterje.
Dober vmesni pristop je: razrešitev polj in pretvorbe kot tukaj (eksplizitno, toleranco na napake tam, kjer je potrebna), vendar z generirano kodo (npr. preko internih predlog) namesto ročno napisane.
Sklep: stabilnost skozi eksplicitna pravila – z jasnimi mejami uporabe
Pri legacy-datasetih z aliasi, neobveznimi stolpci in zgodovinsko interpretacijo NULL je mapiranje datasetov v objekte uspešno predvsem, če ostane eksplicitno in diagnostično. Načrt mapiranja iz seznamov kandidatov, zahtevano/neobvezno in konverterjev to doseže: postopoma lahko stabilizirate zapuščene rešitve, ne da bi takoj uvedli ORM ali bazo podatkov „naenkrat“ normalizirali.
Meje so pri ekstremni zmogljivosti in pri zelo številnih tipih – takrat potrebujete predpomnjenje ali samodejno generiranje kode. Za tipično poslovno programsko opremo z razvitimi procesi pa je pristop zanesljivo orodje za ponovno ločevanje dostopa do podatkov in modelov domene ter za izboljšanje vzdrževanja.
Če pri konkretnem legacy-mapiranju (FireDAC, Views, razrast joinov, Null-Semantik) potrebujete drugo mnenje ali zanesljivo ciljano arhitekturo, je naslednji korak običajno kratka analiza z reproducibilnimi primeri. Kontakt:
V strokovnem okolju imajo tudi Delphi Dataset Mapping in Legacy Delphi pomembno vlogo, ko morajo integracije, pretoki podatkov in nadaljnji razvoj čisto sodelovati.
Prediskutirajte projekt ali modernizacijski podvig z Net-Base.