У вже розвинених Delphi-системах відображення Dataset у об’єкт рідко буває чистим «одне поле = одна властивість». У індивідуальному корпоративному ПЗ натомість зустрічаються псевдоніми-стовпці з Views, результати JOIN із дубльованими іменами полів, «порожні» значення як 0 або ' ', типізовані поля, які сьогодні повертають VARCHAR, а завтра — INTEGER, і стовпці, яких взагалі може не бути залежно від діалогу пошуку. Саме на цьому багато маперів «ламаються»: або вони стають надто «магічними» (і важко відлагоджуваними), або настільки строгими, що вже одне опціональне поле зупиняє роботу.
Цей фрагмент коду демонструє прагматичний мапер для Delphi, який свідомо не є ORM, але коректно обробляє найважливіші крайові випадки у спадщині: однозначне розв’язання імен полів, контрольоване перетворення, семантика Null, опціональні поля та зрозумілі повідомлення про помилки. Він підходить для Data-Access-Layer (DAL, тобто шару, який інкапсулює доступ до даних) або для Repository-патернів — і добре комбінується з BDE-Ablosung з нативним підключенням (бібліотека доступу до даних Delphi для багатьох СУБД).
Чому стандартне маппінг рішень зазнає невдачі на старих структурах
Кілька типових причин з експлуатації, які при «чистому» новому дизайнi трапляються рідко:
- Двозначні імена полів: JOIN повертає
IDз кількох таблиць; у Dataset воно може називатисьID,ID_1або бути перейменованим через SQL-аліас. - Семантичні Null-и:
0означає «невідомо»,'1899-12-30'— «немає дати»,' '— «не заповнено». - Коливання типів: View не робить кастингу; драйвер повертає
ftWideStringзамістьftInteger. Конвертація Variant стає джерелом помилок. - Опціональні стовпці: Для діалогу пошуку залежно від фільтра використовуються різні SELECT-списки. Код же очікує поля «завжди».
- Можливість відлагодження: Якщо маппінг «зникає» в RTTI, пошук причин помилок у даних клієнта ускладнюється (яке поле, яке значення, який тип?).
Підхід: план маппінгу замість конвенції, з контрольованим перетворенням
Ядро підходу — це план маппінгу: список правил «властивість X береться з поля A або B, є обов’язковою/опціональною, використовує конвертер Y». Це зберігає декларативність маппінгу, але не робить його «невидимим», як у багатьох ORM-механізмах. Крім того, мапер може кинути інформативний виняток для кожного поля з включенням імені поля, типу даних і сирого значення.
Важливо: ми навмисно мапимо з TDataSet, а не з конкретного класу BDE-Ablosung mit nativer Anbindung. Це забезпечує сумісність з TFDQuery, TClientDataSet або сторонніми компонентами.
Фрагмент коду: відлагоджуване відображення Dataset у об’єкт для стовпців зі спадщини
Код реалізує:
- Розв’язання полів через список пріоритетів (аліаси/резерви)
- Обробку обов’язкових/необов’язкових полів
- Семантику Null через конвертери (наприклад
0 => Null) - Стабільні повідомлення про помилки з контекстом
- Хук для відлагодження, щоб відтворювати проблеми маппінгу під час тестування або у випадку підтримки
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
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
function FieldTypeToString(FT: TFieldType): string;
function VariantToDiag(const V: Variant): string;
public
property OnDebug: TMapDebugEvent read FOnDebug write FOnDebug;
// MapOne: викликає сеттер для кожної Spec. Жодного RTTI: явне присвоєння легше відлагоджувати.
procedure MapOne(DS: TDataSet; const Specs: TArray
const Assign: TProc
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
var
Name: string;
begin
Result := nil;
for Name in Candidates do
begin
// FindField замість FieldByName: опційно, без Exception
Result := DS.FindField(Name);
if Result <> nil then
Exit;
end;
end;
procedure TLegacyDatasetMapper.MapOne(DS: TDataSet; const Specs: TArray
const Assign: TProc
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(‚Помилка маппінгу: Required-поле для %s не знайдено. Кандидати: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // опційно: просто пропустити
end;
Raw := F.Value; // Variant; враховує 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 після конвертера — це помилка (частіше, ніж здається)
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 є Required, але значення 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);
// Навмисно строго: ‚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, тобто транспортний об’єкт для даних) 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;Мета: Мапінг описаний централізовано в одному місці (Specs), але присвоєння залишається явним. У Legacy-ситуаціях це зазвичай краще компромісне рішення, ніж повністю автоматичне RTTI-мапування, оскільки ви відразу бачите, яка властивість залежить від яких імен полів.
Передумови: Підхід очікує активний Dataset і поточну позицію запису. Для пакетного імпорту перебирайте зовні за допомогою while not DS.Eof do і викликайте MapCustomer для кожного рядка.
Підводні камені: Zверніть увагу на VarToStr при BLOBs або Memo-полях; там слід використовувати власні конвертери. І: «Required» означає тут після конвертера. Якщо C_TrimToNull встановлює required-поле в Null, то це навмисно — якість даних має вирішуватися на джерелі або в процесі.
Варіанти: Замість String-Targets можна також використовувати Enum, щоб виключити опечатки. Альтернативно Assign-функцію для кожного Spec можна зберігати як TProc<Variant>, тоді Target-рядок взагалі не потрібен (трохи більше boilerplate-коду, але ще менше ризику помилок).
Einordnung in Architektur: DAL/Repository, Logging und Betrieb
У багатошаровій архітектурі (типово: UI – Business – Datenzugriff) це мапування належить до шару доступу до даних або до репозиторію. Важливо, щоб Dataset не «передавалося» далі: об’єкти/DTOs є більш стійким інтерфейсом, особливо якщо пізніше ви додаватимете REST-APIs або виведете частини у C# Services.
Для експлуатації та підтримки варто використовувати Debug-Hook OnDebug. За його допомогою в тестах або при відтворюваних зверненнях у службу підтримки можна протоколювати, які поля фактично були змеплені. У продуктивних системах це має бути цілеспрямовано і з можливістю вимкнення, інакше логування стане занадто дорогим або надто об’ємним щодо даних.
Розумне використання Debug-Hook
- Unit-Tests: перевірити, чи певний SQL-вираз дійсно повертає всі Required-поля.
- Діагностика: при проблемах у клієнтів ви відразу бачите «поля не було» vs. «значення не вдалося перетворити».
- Фази міграції: при перейменуванні Views/імен стовпців ви можете паралельно підтримувати списки кандидатів, поки все не буде перенесено.
Коли цей підхід втрачає ефективність (і що в таких випадках краще)
Показане Dataset-до-об’єкта мапування ефективне, коли джерело даних ненадійне і вам все одно потрібна детермінована поведінка. Воно зазвичай втрачає ефективність у двох ситуаціях:
- Дуже великі обсяги (наприклад, масовий експорт): Variant-конвертація і пошук за іменем поля можуть стати помітними з точки зору продуктивності. Тоді виправдане попередньо обчислене кешування індексу полів для кожного SQL (наприклад,
FieldByNameодин раз на Dataset, не на Row). - Дуже багато DTO-Typen: якщо ви пишете сотні мапперів, шаблонний (boilerplate) код стає проблемою. У такому випадку має сенс підхід на основі RTTI з атрибутами — але лише якщо ви суворо контролюєте відладкові виводи і конвертери.
Хороший компроміс: вирішення полів і конвертація як тут (явно, терпимі до помилок там, де потрібно), але з генерованим кодом (наприклад, через внутрішні шаблони) замість «ручного» написання.
Висновок: стабільність через явні правила — з чіткими межами застосування
У Legacy-Datasets з Aliases, опційними стовпцями та історичною Null-Semantik мапування Dataset-до-об’єкта найуспішніше, коли воно залишається явним і діагностичним. План мапування з кандидатними списками, Required/Optional і конвертерами забезпечує саме це: ви можете поступово стабілізувати спадщину, не вводячи одразу ORM або не нормалізуючи базу даних «одним махом».
Межі лежать у випадках екстремальної продуктивності та при дуже великій кількості типів — тоді потрібне кешування або автоматизоване генерування коду. Для типового бізнес‑ПЗ з еволюційними процесами цей підхід є надійним важелем, щоб розв’язати доступ до даних і доменні моделі та зробити їх підтримуваними.
Якщо вам потрібна друга думка або надійна цільова архітектура для конкретного Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik), наступний крок зазвичай — короткий аналіз з відтворюваними прикладами. Контакт:
У предметній області також важливу роль відіграють Delphi Dataset Mapping та Legacy Delphi, коли інтеграції, потоки даних і подальший розвиток мають працювати скоординовано.