Во разгранети Delphi-системи маپирањето од Dataset кон објект ретко е чистиот „едно поле = една Property“ случај. Во индивидуален корпоративен софтвер наместо тоа ќе наидете на alias-колони од Views, резултати од Join со дупли имиња на полиња, „празни“ вредности како 0 или ' ', типизирани полиња кои денес враќаат VARCHAR а утре INTEGER, и колони кои во зависност од дијалогот за пребарување едноставно ги нема. Токму таму многу mapper-и се сопнуваат: или стануваат „магични“ (и тешко е да се дебагира), или се толку строги што само едно опционално поле го запира работењето.
Овој извадок од изворен код покажува еден прагматичен mapper за Delphi, кој намерно не е ORM, но чисто адресира најважните legacy-рабни случаи: еднозначно решавање на полињата, контролирана конверзија, семантика на Null, опционални полиња и разложливи пораки за грешки. Тој е погоден за Data-Access-Layer (DAL, односно слој што ја капсулира пристапот до податоци) или за репозитори-патерни – и лесно се комбинира со BDE-Ablosung mit nativer Anbindung (библиотека за пристап до податоци на Delphi за многу DBs).
Зошто стандардното мапирање кај старите структури не успева
Неколку типични причини од оперативна работа, кои ретко се среќаваат при „чист“ нов дизајн:
- Двосмислени имиња на полиња: Join враќа
IDод повеќе табели; во Dataset тогаш се викаID,ID_1или е преименувано со SQL-алијас. - Семантички NULL-ови:
0значи „непознато“,'1899-12-30'е „нема датум“,' 'е „невнесено“. - Променливи типови: View-то не прави cast; драјверот враќа
ftWideStringнаместоftInteger. Конверзијата на Variant станува извор на грешки. - Опционални колони: Дијалогот за пребарување користи различни SELECT-листи во зависност од филтерот. Кодот сепак очекува полиња „секогаш“.
- Дебагабилност: Кога мапирањето ќе се изгуби во RTTI, пронаоѓањето на грешки во податоците на клиентот е тешко (кое поле, која вредност, кој тип?).
Пристап: План за мапирање наместо конвенција, со контролирана конверзија
Јадрото е еден Mapping-Plan: листа на правила „Property X доаѓа од поле A или B, е опционално/обврзувачко, користи конвертор Y“. На тој начин мапирањето останува декларативно, но не „невидливо“ како кај многу ORM-механизми. Дополнително, mapper-от може за секое поле да фрли јасна исклучок порака, вклучувајќи име на поле, тип на податок и сурова вредност.
Важно: Намерно мапираме од TDataSet, а не од конкретна BDE-Ablosung mit nativer Anbindung-класа. Со тоа останува компатибилно со TFDQuery, TClientDataSet или со трети компоненти.
Извадок од изворен код: Дебагирано Dataset-до-Објект мапирање за наследени колони
Кодот ја имплементира:
- Резолуција на поле преку листа на приоритети (алијаси/резервни опции)
- Ракување со обврзителни/опционални полиња
- Семантика на 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);
// Конверторот добива Variant и враќа Variant (на пр. Null, Integer, String, TDateTime како 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: повикува сетери за секоја спецификација. Без RTTI: експлицитното присвојување е полесно за дебагирање.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Помошни конвертори
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 наместо FieldByName: опционално можно, без исклучок
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('Грешка при мапирање: Задолжително поле за %s не е пронајдено. Кандидати: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // опционално: едноставно прескокни
end;
Raw := F.Value; // Variant; berücksichtigt 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;
// Задолжително: NULL по конвертор е грешка (почеста отколку што се мисли)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Грешка при мапирање: %s е задолжително, но вредноста е NULL по конверзија. Поле %s (%s), сурова вредност=%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('Грешка при мапирање за %s од поле %s (%s), сурова вредност=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Конвертори }
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);
// толерира и '0' како стринг
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);
// Намeрно строго: нема 'Try' што би го сокрил квалитетот на податоците.
// Форматот може да варира во зависност од Legacy; по потреба овде се параметризира преку TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Как практично да го користите Mapper-от (без RTTI, но сепак елегантно)
Der Mapper ruft eine Assign(TargetMember, Value)-Callback-Funktion auf. Das hält die Zuweisung explizit (und damit gut debugbar) und vermeidet RTTI-Zugriffe im Hot-Path. In der Praxis bauen Sie pro Objekt/DTO (Data Transfer Object, also ein Transportobjekt für Daten) einen kleinen „Zuweiser“.
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;Цел: Mapping-от е опишан централизирано на едно место (Specs), но доделувањето останува експлицитно. Во Legacy-ситуации тоа обично е подобар компромис отколку целосно автоматско RTTI-Mapping, бидејќи веднаш гледате која Property зависи од кои имиња на полиња.
Преуслови: Овој пристап очекува активно Dataset и тековна позиција на рекорд. За пакетни увези итерате надвор со while not DS.Eof do и повикувате MapCustomer за секој ред.
Потенцијални замки: Внимавајте на VarToStr кај BLOB-ови или memo-полета; таму треба да користите сопствени конвертори. И: „Required“ тука значи по конверторот. Ако C_TrimToNull постави поле со „Required“ на Null, тоа е намерно — квалитетот на податоците тогаш треба да се реши на изворот или во процесот.
Варијанти: Наместо string-Targets можете да користите и Enum, за да ги исклучите правописните грешки. Алтернативно, Assign-функцијата може да се зачува по Spec како TProc<Variant>, тогаш Target-стрингот целосно отпаѓа (малку повеќе boilerplate, но уште помал ризик од грешки).
Позиционирање во архитектурата: DAL/Repository, логирање и оперативна работа
Во слојна архитектура (типично: UI – Business – Datenzugriff) ова mapping припаѓа во слојот за пристап до податоци или во репозиториум. Важно е Dataset-от да не се „пренесува“: објектите/DTOs се постабилниот интерфејс, особено ако подоцна ќе додавате REST-APIs или ќе изнесувате делови во C# Services.
За оперативна работа и поддршка се исплати Debug-Hook OnDebug. Со него во тестови или при репродуцирање на случаи за поддршка можете да бележите кои полиња всушност беа мапирани. Во продукциски системи тоа треба да се користи селективно и да може да се исклучи, инаку логирањето станува преглатко или пренапорно по податоци.
Практична употреба на Debug-Hook
- Unit-тестови: Проверете дали одреден SQL-израз навистина ги враќа сите полја означени како Required.
- Дијагноза: При проблеми кај клиенти веднаш ќе видите „полето не постоеше“ спротивно на „вредноста не можеше да се конвертира“.
- Фази на миграција: При префрлување на Views/имена на колони можете паралелно да ги одржувате листите на кандидати додека сè не е префрлено.
Кога овој пристап станува неефикасен (и што е тогаш подобро)
Прикажаното мапирање од Dataset до објект е робустно кога изворот на податоци е нестабилен и ви треба детерминистичко однесување. Обично пропаѓа во две ситуации:
- Многу големи количини (напр. масовен экспорт): конверзијата на Variant и пребарувањето по име на поле може да стане забележливо. Тогаш се исплати претпрорачувано кеширање на индексите на полињата по SQL (напр.
FieldByNameеднократно по Dataset, не по Row). - Многу DTO-типови: Ако пишувате стотици мапери, ќе се појави голема количина повторлив код (boilerplate). Тогаш RTTI-базиран пристап со атрибути може да биде целисходен – но само ако строго ги контролирајте Debug-извештаите и конверторите.
Добар компромис е: решавање на полињата и конверзијата како тука (експлицитно, толерантно на грешки каде што е потребно), но со генериран код (напр. преку интерни шаблони) наместо „рачно напишан“.
Заклучок: Стабилност преку експлицитни правила — со јасни граници на применливост
За Legacy-Datasets со алиаси, опционални колони и историска Null-семантика, мапирањето од Dataset до објект е успешено пред сè кога останува експлицитно и дијагностички корисно. Планот за мапирање составен од листи на кандидати, Required/Optional и конвертори го постигнува токму тоа: можете постепено да ги стабилизирате наследените обврски без да воведете ORM веднаш или да ја нормализирате базата на податоци „одеднаш“.
Границите се при екстремни перформанси и при многу типови — тогаш ви треба кеширање или автоматизирано генерирање на код. За типична бизнис-софтвер со развиени процеси, сепак, пристапот е доверлив механизам за да се одделат пристапот до податоци и доменските модели и да станат одржливи и лесни за одржување.
Ако ви треба второ мислење или робусна целна архитектура за конкретно Legacy-мапирање (FireDAC, Views, неконтролиран раст на Join-ови, Null-семантика), следниот чекор обично е кратка анализа со репродуктивни примери. Контакт:
Во стручниот контекст, и Delphi Dataset Mapping и Legacy Delphi играат важна улога кога интеграциите, тековите на податоци и натамошниот развој мора да функционираат чисто заедно.
Разговарајте за проект или модернизациски зафат со Net-Base.