При развијеним Delphi-системима је mapiranje из Dataset-а у objekat ретко чист случај „једно поље = једно својство“. У индивидуалном софтверу за предузећа уместо тога срећете алијас-колоне из Views, резултате JOIN са дуплим именима поља, „празне“ вредности као 0 или ' ', типизирана поља која данас враћају VARCHAR а сутра INTEGER, и колоне које у зависности од дијалога за претрагу једноставно нису присутне. Управо ту многи мапери запну: или постану „магични“ (и тиме тешко подложни дебаговању), или су толико строги да већ једно опционално поље заустави рад.
Овај исечак кода показује прагматичан мапер за Delphi, који свесно није ORM, али чисто адресира најважније legacy-случајеве: јединствено разлучивање поља, контролисана конверзија, NULL-семантика, опционална поља и пратеће разумљиве поруке о грешкама. Погодан је за Data-Access-Layer (DAL, односно слој који капсулира приступ подацима) или Repository-патерне – и добро се комбинује са BDE-заменом са нативном везом (Delphis библиотека за приступ подацима за многе DBs).
Зашто стандардно мапирање на старим структурама не успева
Нека типична појава из производње коју се при „чистом“ редизајну ретко среће:
- Двосмислена имена поља: JOIN враћа
IDиз више табела; у Dataset-у се онда зовеID,ID_1или је преназван SQL-алијасом. - Семантички NULL-ови:
0значи „непознато“,'1899-12-30'је „није датум“,' 'је „није унето“. - Променљиви типови: View не radi cast; драјвер враћа
ftWideStringуместоftInteger. Variant-конверзија постаје извор грешака. - Опционалне колоне: Дијалог за претрагу користи, у зависности од филтера, различите SELECT-листе. Код ипак очекује да су поља „увек“ присутна.
- Дебагабилност: Ако мапирање нестане у RTTI, тражење грешке у клијентским подацима постаје тешко (које поље, која вредност, који тип?).
Приступ: план мапирања уместо конвенције, са контролисаном конверзијом
Језгро је у плану мапирања: листи правила „својство X долази из поља A или B, је опционално/обавезно, користи конвертер Y“. На тај начин мапирање остаје декларативно, али не „невидљиво“ као код многих ORM-механизама. Поред тога, мапер по пољу може бацити смислену изузетну поруку која садржи име поља, тип податка и сирову вредност.
Важно: намерно мапирамо из TDataSet, а не из конкретне BDE-Ablosung mit nativer Anbindung-класе. Тако остаје компатибилно са TFDQuery, TClientDataSet или компонентама трећих страна.
Исечак кода: дебаговано mapiranje из Dataset-а у objekat за legacy-колоне
Код имплементира:
- Резолуцију поља преко листе приоритета (алијаси / резерве)
- Обраду обавезних/опционалних поља
- NULL-семантику преко конвертера (нпр.
0 => Null) - Стабилне поруке о грешкама са контекстом
- Debug-hook који омогућава репродуковање проблема са мапирањем у тесту или у служби подршке
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 svaku Spec. Bez RTTI: eksplicitno dodeljivanje je bolje za debugovanje.
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
// Koristi FindField umesto FieldByName: opciono moguće, bez izuzetka
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 није активан.‘);
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(‚Greška mapiranja: obavezno polje za %s nije pronađeno. Kandidati: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opciono: jednostavno preskočiti
end;
Raw := F.Value; // Variant; uvažava 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;
// Obavezno: NULL posle konverzije je greška (češće nego što mislite)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Greška mapiranja: %s je obavezan, ali vrednost je NULL nakon konverzije. Polje %s (%s), sirova 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(‚Greška mapiranja za %s iz polja %s (%s), sirova vrednost=%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);
// 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);
// Namerno strikt: nema „Try“ koji bi ugušio kvalitet podataka.
// Format može varirati u zavisnosti od legacy sistema; po potrebi parametrizovati ovde preko TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Како практично користити Mapper (без RTTI, али ипак елегантно)
Mapper позива Assign(TargetMember, Value) callback-функцију. То чини доделу експлицитном (а тиме и добро дебагабилном) и избегава RTTI-припаде на Hot-Path. У пракси правите по један мали „доделјивач“ за сваки објекат/DTO (Data Transfer Object, односно транспортни објекат за податке).
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 једном месту (Specs), али додела остаје експлицитна. У Legacy-ситуацијама ово је често бољи компромис од потпуно аутоматског RTTI-mapping-а, јер одмах видите која својство зависи од којих имена поља.
Preduslovi: Приступ очекује активно Dataset и тренутну позицију рекорда. За батч импорте итерајте споља преко while not DS.Eof do и позивајте MapCustomer за сваки ред.
Zamke: Obratite pažnju na VarToStr код BLOB-ova или memo-полја; тамо треба да користите своје конверторе. И: „Required“ значи овде после конвертора. Ако C_TrimToNull постави обавезно поље на NULL, то је намерно — квалитет података мора бити решен на извору или у процесу.
Varijante: Уместо string-Targets можете користити и enum да елиминишете типографске грешке. Алтернативно, можете за сваки Spec сачувати Assign-функцију као TProc<Variant>, чиме потпуно елиминишете Target-string (некако више boilerplate-а, али још мање извора грешака).
Распоређивање у архитектури: DAL/Repository, Logging и рад у производњи
У слојној архитектури (типично: UI – Business – приступ подацима) ово мапирање припада слоју за приступ подацима или repository-ју. Важно је да се Dataset не „преноси“ даље: објекти/DTOs су стабилнији интерфејс, посебно ако касније додајете REST-APIs или измештате делове у C# servise.
За оперативу и подршку корисно је имати debug-hook OnDebug. Помоћу њега у тестовима или при понављаним случајевима подршке можете евидентирати која су поља заправо мапирана. У продуктивним системима то треба бити циљано и могуће искључити, иначе ће логовање постати прескупо или ће резултовати превеликом количином података.
Смислено коришћење Debug-Hook
- Јединични тестови: Проверите да ли одређени SQL-израз заиста враћа сва обавезна поља.
- Дијагностика: При проблемима код клијента одмах видите разлику „поље није било присутно“ у односу на „вредност се није могла конвертовати“.
- Фазе миграције: При пребацивању Views/именa колона можете паралелно одржавати листе кандидата док све не буде преселено.
Када овај приступ закаже (и шта је онда боље)
Приказано Dataset-до-објекта мапирање је робусно када је извор података „буčan“ и ипак вам треба детерминисано понашање. Обично закаже у две ситуације:
- Веома велики обими (нпр. масовни експорт): конверзија Variant вредности и претрага по имену поља може постати мерљива. Тада се исплати унапред израчунато кеширање индекса поља по SQL-упиту (нпр.
FieldByNameједнократно по Dataset-у, не по реду). - Веома много DTO типова: Ако пишете стотине мапера, појавиће се много шаблонског кода. Тада може бити користан RTTI-базиран приступ са атрибутима — али само ако строги контролишете дебаг-излазе и конверторе.
Добар компромис је: решавање поља и конверзија као овде (експлицитно, толерантно на грешке где је потребно), али са генерисаним кодом (нпр. преко интерних шаблона) уместо „ручно писаног“.
Закључак: Стабилност кроз експлицитна правила — са јасним границама примене
Код наследних Dataset-ова са alias-има, опционалним колонама и историјском Null-семантиком, Dataset-до-објекта мапирање је посебно успешно када остане експлицитно и дијагностички способно. План мапирања заснован на листама кандидата, обавезно/опционо и конверторима управо то обезбеђује: можете постепено стабилизовати наслеђе без увођења ORM-а или „одједном“ нормализације базе података.
Границе су при екстремним захтевима за перформансама и код врло великог броја типова — тада вам треба кеширање или аутоматизовано генерисање кода. За типичан бизнис софтвер са развијеним процесима, међутим, приступ представља поуздан инструмент да поново раздвојите приступ подацима и доменске моделе и учините их одрживијим.
Ако вам за конкретно наследно мапирање (FireDAC, Views, неконтролисани Join-ови, Null-семантика) треба друго мишљење или поуздана циљна архитектура, следећи корак је обично кратка анализа са репродукованим примерима. Контакт:
У стручном окружењу такође играју важну улогу Delphi Dataset Mapping и Legacy Delphi када интеграције, токови података и даљи развој морају чисто да заједно функционишу.