Net-Base Ajakiri

10.05.2026

Andmekogumi-objekti kaardistamine ebaharilike pärandstruktuuride jaoks: stabiilne, silumisvõimeline, ilma ORM-maagiata

Kui pärandandmestikud on ajalooliselt kasvanud, komistavad standardmapperid sageli alias-veergude, tüübisegude ja muutuvate join-struktuuride tõttu. See lähtekoodilõik näitab robustset, silumist võimaldavat andmestikust-objekti kaardistamist Delphi: koos kaardistamisplaaniga, konverteritega ja null-semantikaga...

10.05.2026

Kasvavate Delphi-süsteemide puhul on andmekogumi–objekti mappimine harva puhtalt „üks väli = üks property“. Individuaalse ettevõtte tarkvara puhul kohtate selle asemel vaadetest pärit alias-välju, JOIN-tulemusi topeltväljanimedega, „tühje“ väärtusi nagu 0 või ' ', tüübitud välju, mis täna annavad VARCHAR ja homme INTEGER, ning veerge, mida sõltuvalt otsingudialoogist lihtsalt ei ole. Just seal eksivad paljud mapperid: kas need muutuvad liiga „maagilisteks“ (ja seetõttu raskesti debugitavateks), või on nad nii ranged, et juba üks valikuline väli peatab töö.

See lähtekoodinäide näitab pragmaatilist mapperit Delphi jaoks, mis on teadlikult mitte ORM, kuid käsitleb puhtalt olulisemaid legacy-äärjuhtumeid: ühemõtteline väljaeristus, kontrollitud konverteerimine, null-semantika, valikulised väljad ja jälgitavad veateated. Sobib Data-Access-Layeri (DAL, ehk kiht, mis kapseldab andmepääsu) või repository-mustrite jaoks – ja kombineerub hästi BDE-asendamisega natiivse ühendusega (Delphi andmepääsubiblioteek paljudele andmebaasidele).

Miks tavaline mappimine vanades struktuurides läbi kukub

Mõned tüüpilised tööpõhised põhjused, mida „puhas“ uuesti kujundamise puhul harva näeb:

  • Mitmetähenduslikud väljanimed: JOIN annab ID mitmest tabelist; datasetis ilmub see siis ID, ID_1 või on see SQL-alias’ga ümber nimetatud.
  • Semantilised nullid: 0 tähendab „tundmatu“, '1899-12-30' tähendab „pole kuupäeva“, ' ' tähendab „pole täidetud“.
  • Muutuvad tüübid: View ei tee casti; draiver kannab üle ftWideString asemel ftInteger. Variantide konverteerimine muutub vigade allikaks.
  • Valikulised veerud: Otsingudialoog lisab sõltuvalt filtrist erinevaid SELECT-vaid. Kood eeldab aga tihti, et väljad on „alati“ olemas.
  • Debugitavus: Kui mappimine kaob RTTI-sse, on kliendiandmete põhjal veaotsing keeruline (milline väli, milline väärtus, milline tüüp?).

Lähenemine: mappimise plaan konventsiooni asemel, koos kontrollitud konverteerimisega

Tuum on mappimise plaan: reeglite loend „Property X tuleb väljalt A või B, on valikuline/nõutav, kasutab konverterit Y“. Sel viisil jääb mappimine deklaratiivseks, kuid mitte „nähamatuks“ nagu paljude ORM-mehhanismide puhul. Lisaks saab mapper iga välja puhul visata selge erandi, mis sisaldab välja nime, andmetüüpi ja toorväärtust.

Oluline: me mäppime teadlikult TDataSet-ist, mitte mingist konkreetsest BDE-Ablosung mit nativer Anbindung-klassist. Nii jääb lahendus ühilduvaks TFDQuery, TClientDataSet ja kolmandate osapoolte komponentidega.

Lähtekooditükk: debugitav dataseti–objekti mappimine legacy-veergude jaoks

Kood realiseerib:

  • Väljade eristamise prioriteetide nimekirja alusel (aliased / fallbackid)
  • Kohustuslike/valikuliste väljade käsitlemine
  • Null-semantika konverterite kaudu (nt 0 => Null)
  • Stabiilsed veateated koos kontekstiga
  • Debug-hook, mis võimaldab mappimise probleeme testis või tugijuhtumil jälitada
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);

  // Konverter võtab Varianti ja tagastab Varianti (nt Null, Integer, String, TDateTime double'ina)
  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: kutsub iga spec-i jaoks setter'i. Ei kasuta RTTI-d: otsene määramine on paremini debugitav.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Abikonverterid
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 asemel: võimalus olla valikuline, ilma erandita
    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 ei ole aktiivne.');

  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('Mapping-viga: Required-väli %s ei leitud. Kandidandid: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // valikuline: lihtsalt jätta vahele
    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;

      // Required: NULL pärast konvertorit on viga (sagedamini kui arvata)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Mapping-viga: %s on Required, aga väärtus on NULL pärast konverteerimist. Välja %s (%s), algväärtus=%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('Mapping-viga: %s väljalt %s (%s), algväärtus=%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);
    // talub ka '0' stringina
    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);

    // Tahtlikult range: 'Try' ei varja andmekvaliteedi probleeme.
    // Formaat võib legacy puhul erineda; vajadusel parametriseerida siin TFormatSettings kaudu.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Kuidas Mapperit praktiliselt kasutada (ilma RTTI-ta, ent siiski elegantselt)

Mapper kutsub välja Assign(TargetMember, Value)-tagasikutsekutsefunktsiooni. See hoiab määramise eksplicitse (ja seega hästi debugitava) ning väldib RTTI-kutseid hot-path’is. Praktikas ehitate iga objekti/DTO (Data Transfer Object, ehk andmete edastusobjekt) jaoks väikese „määraja“.

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;

Eesmärk: Mappimine on ühes kohas tsentraalselt kirjeldatud (Specs), kuid määramine jääb eksplicitseks. Legacy-situatsioonides on see sageli parem kompromiss kui täielikult automaatne RTTI-mappimine, sest näete kohe, milline property sõltub millisest väljanimest.

Eeldused: Selle lähenemise eelduseks on aktiivne DataSet ja praegune record-positsioon. Hulgimooduli importide puhul iterereerige väljastpoolt üle while not DS.Eof do ja kutsuge iga rea jaoks MapCustomer üles.

Püünised: Pöörake tähelepanu VarToStr-ile BLOB-ide või memo-väljade puhul; seal peaksite kasutama oma konvertereid. Ja: „Required“ tähendab siin pärast konverterit. Kui C_TrimToNull seab Required-välja nulliks, on see tahtlik — andmete kvaliteet tuleb siis lahendada kas allikas või protsessi tasandil.

Variandid: String-targetide asemel võite kasutada ka Enumit, et välistada trükivead. Alternatiivselt võib Assign-funktsiooni salvestada iga Spec-i jaoks kui TProc<Variant>, sel juhul kaob Target-string täielikult (veidi rohkem boilerplate’i, kuid veel vähem vigasid).

Asetus arhitektuuris: DAL/Repository, logimine ja käitamine

Layer-arkitektuuris (tüüpiline: UI – Business – andmepääs) kuulub see mappimine andmepääsukihile või repositooriumisse. Oluline on, et DataSeti ei „läkitata“ edasi: objektid/DTO-d on stabiilsem liides, eriti kui te hiljem REST-API-sid järeltöödeldate või osi välja viite C# Services.

Für Betrieb und Support lohnt sich der Debug-Hook OnDebug. Sie können damit in Tests oder bei reproduzierbaren Supportfällen protokollieren, welche Felder tatsächlich gemappt wurden. In produktiven Systemen sollte das gezielt und abschaltbar sein, sonst wird Logging zu teuer oder zu datenhaltig.

Debug-Hook sinnvoll nutzen

  • Ühikutestid: Kontrollige, ob ein bestimmtes SQL-Statement wirklich alle kohustuslikud väljad liefert.
  • Diagnoos: Kliendiprobleemide korral näete kohe „väli puudus“ vs. „väärtust ei õnnestunud konverteerida“.
  • Migratsioonifaasid: Beim Umstellen von Views/Spaltennamen können Sie Kandidatenlisten parallel pflegen, bis alles umgezogen ist.

Wann dieser Ansatz kippt (und was dann besser ist)

Das gezeigte Dataset-zu-Objekt Mapping ist stark, wenn die Datenquelle unruhig ist und Sie trotzdem deterministisches Verhalten brauchen. Es kippt typischerweise in zwei Situationen:

  • Väga suured mahud (z. B. Massenexport): Variant-Konvertierung und per Feldname suchen kann spürbar werden. Dann lohnt sich ein vorberechnetes Feldindex-Caching pro SQL (z. B. FieldByName einmalig pro Dataset, nicht pro Row).
  • Väga palju DTO-tüüpe: Kui Sie hunderte Mapper schreiben, wird Boilerplate zum Thema. Dann kann ein RTTI-basierter Ansatz mit Attributen sinnvoll sein – aber nur, wenn Sie Debug-Ausgaben und Konverter strikt kontrollieren.

Ein guter Zwischenweg ist: Feldauflösung und Konvertierung wie hier (explizit, fehlertolerant wo nötig), aber mit generiertem Code (z. B. über interne Templates) statt „handgeschrieben“.

Fazit: Stabilität durch explizite Regeln – mit klaren Einsatzgrenzen

Bei Legacy-Datasets mit Aliases, optionalen Spalten und historischer Null-Semantik ist Dataset-zu-Objekt Mapping vor allem dann erfolgreich, wenn es explizit und diagnosefähig bleibt. Der Mapping-Plan aus Kandidatenlisten, Required/Optional und Konvertern schafft genau das: Sie können Altlasten schrittweise stabilisieren, ohne gleich ein ORM einzuführen oder die Datenbank „auf einmal“ zu normalisieren.

Die Grenzen liegen bei Extrem-Performance und bei sehr vielen Typen – dann brauchen Sie Caching oder automatisierte Code-Erzeugung. Für typische Business-Software mit gewachsenen Prozessen ist der Ansatz jedoch ein verlässlicher Hebel, um Datenzugriff und Domänenmodelle wieder entkoppelt und wartbar zu bekommen.

Wenn Sie bei einem konkreten Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) eine zweite Meinung oder eine belastbare Zielarchitektur brauchen, ist der nächste Schritt meist eine kurze Analyse mit reproduzierbaren Beispielen. Kontakt:

Im fachlichen Umfeld spielen auch Delphi Dataset Mapping und Legacy Delphi eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Arutada projekti või moderniseerimisettevõtmist koos Net-Base.

Jaga postitust

Jaga seda postitust otse

LinkedIn, X, XING, Facebook, WhatsApp ja e-post on kohe saadaval. Instagrami jaoks valmistame kohe lingi ja lühiteksti ette.

e-post

Instagram avatakse uues vahekaardis. Link ja lühitekst kopeeritakse eelnevalt lõikepuhvrisse.