Net-Base списание

10.05.2026

Мапирање од Dataset кон објект за необични наследени структури: стабилно, лесно за дебагирање, без ORM-магии

Кога наследените датасети се историски формирани, стандардните мапери често не се справуваат со колони со алијаси, мешање на типови и променливи join-структури. Овој исечок од изворен код покажува робустно, лесно за дебагирање мапирање од датасет кон објект во Delphi: со план за мапирање, конвертори и семантика на NULL...

10.05.2026

Во разгранети 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, за да може проблемите со мапирањето при тестирање или во случај на поддршка да се реконструираат
Delphi
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“.

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;

Цел: 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.

Сподели објава

Споделете го овој пост директно.

LinkedIn, X, XING, Facebook, WhatsApp и е-пошта се веднаш достапни. За Instagram директно подготвуваме линк и краток текст.

Е-пошта

Instagram се отвора во нов таб. Линкот и краткиот текст претходно се копираат во меѓуспремникот.