Net-Base Журнал

10.05.2026

Відображення Dataset у об'єкти для нестандартних успадкованих структур: стабільно, зручне для відлагодження, без прихованої ORM-логіки

Коли набори даних історично сформувалися, стандартні мапери часто ламаються на аліасних стовпцях, змішаних типах і змінних JOIN-структурах. Цей фрагмент коду демонструє надійне, придатне для налагодження відображення набору даних у об'єкт у Delphi: з планом відображення, конвертерами, семантикою null...

10.05.2026

У вже розвинених 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): TField;
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): TField;
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“.

Delphi
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, коли інтеграції, потоки даних і подальший розвиток мають працювати скоординовано.

Обговорити проєкт або ініціативу модернізації з Net-Base.

Поділитися дописом

Поділитися цим дописом безпосередньо

LinkedIn, X, XING, Facebook, WhatsApp та електронна пошта доступні негайно. Для Instagram ми готуємо посилання та короткий текст безпосередньо.

Електронна пошта

Instagram відкривається в новій вкладці. Посилання та короткий текст попередньо копіюються у буфер обміну.