Net-Base Revija

10.05.2026

Mapiranje datasetov v objekte za nenavadne stare strukture: stabilno, razhroščljivo, brez ORM-magije

Ko so Legacy-Datasets zgodovinsko nastali, se standardni mapperji pogosto zataknejo pri alias-stolpcih, mešanju tipov in spreminjajočih se JOIN-strukturah. Ta izsek iz izvorne kode prikazuje robustno, enostavno za razhroščevanje preslikavo iz dataset-a v objekt v Delphi: z načrtom preslikave, pretvorniki in obravnavo NULL-vrednosti...

10.05.2026

Pri razvitih Delphi-sistemih je mapiranje iz DataSet v objekt redko čisti primer „eno polje = ena lastnost“. V prilagojeni poslovni programski opremi boste namesto tega naleteli na alias-stolpce iz view‑ov, rezultate join‑ov z dvojnimi imeni polj, „prazne“ vrednosti kot 0 ali ' ', tipizirana polja, ki danes vračajo VARCHAR in jutri INTEGER, ter stolpce, ki glede na iskalni dialog preprosto niso prisotni. Prav tam mnogi mapperji odpovejo: bodisi postanejo preveč „magični“ (in zato težje za razhroščevanje), bodisi so tako strogi, da že eno opcijsko polje ustavi delovanje.

Ta izsek izvorne kode prikazuje pragmatičen mapper za Delphi, ki zavestno ni ORM, vendar čisto naslavlja najpomembnejše legacy robne primere: enoznačna razrešitev polj, kontrolirane konverzije, semantiko ničelnih vrednosti, opcijska polja in razumljiva sporočila o napakah. Primeren je za Data-Access-Layer (DAL, torej plast, ki zavije dostop do podatkov) ali za Repository‑pattern – in se dobro kombinira z BDE-zamenjavo z nativno povezavo (knjižnica za dostop do podatkov Delphi za številne DB).

Zakaj standardno mapiranje pri starih strukturah spodleti

Nekaj tipičnih vzrokov iz obratovanja, ki jih pri „čistem“ novem načrtovanju redko vidite:

  • Dvosmiselna imena polj: Join vrne ID iz več tabel; v DataSetu se to pojavi kot ID, ID_1 ali je preimenovano z SQL‑aliasom.
  • Semantične ničelne vrednosti: 0 pomeni „neznano“, '1899-12-30' je „ni datuma“, ' ' je „ni navedeno“.
  • Nihajoči tipi: View ne izvaja pretvorbe tipov; gonilnik vrne ftWideString namesto ftInteger. Variant‑konverzija postane vir napak.
  • Opcijski stolpci: Iskalni dialog glede na filter uporablja različne SELECT‑liste. Koda pa pričakuje polja „vedno“.
  • Možnost razhroščevanja: Če mapiranje izgine v RTTI, je iskanje napak pri podatkih naročnikov oteženo (katero polje, katera vrednost, kateri tip?).

Pristop: načrt mapiranja namesto konvencije, s kontrolirano konverzijo

Jezgro je načrt mapiranja: seznam pravil „Property X prihaja iz polja A ali B, je opcijsko/obvezno, uporablja konverter Y“. Tako ostane mapiranje deklarativno, a ne „nevidno“ kot pri mnogih ORM‑mehanizmih. Poleg tega lahko mapper za vsako polje vrže informativno izjemo, vključno z imenom polja, podatkovnim tipom in surovo vrednostjo.

Pomembno: namerno mapiramo iz TDataSet, ne iz konkretne klase BDE-Ablosung mit nativer Anbindung. Tako ostane združljivo z TFDQuery, TClientDataSet ali tudi s tujimi komponentami.

Izsek kode: razhroščljivo mapiranje iz DataSet v objekt za legacy stolpce

Koda implementira:

  • Razrešitev polj preko seznama prioritet (aliasi/nadomestki)
  • Obravnavo obveznih/izbirnih polj
  • Semantiko ničel preko konverterjev (npr. 0 => Null)
  • Stabilna sporočila o napakah s kontekstom
  • Debug‑hook, ki omogoča reproduciranje težav z mapiranjem v testu ali v primeru podpore
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);

  // Pretvornik prejme Variant in vrne Variant (npr. Null, Integer, String, TDateTime kot 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: pokliče setter za vsak Spec. Brez RTTI: eksplicitna dodelitev je lažje za razhroščevanje.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Pretvorniki
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 namesto FieldByName: omogoča opcijsko iskanje brez izjeme
    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 ni aktiven.');

  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('Napaka preslikave: zahtevanega polja za %s ni bilo najdeno. Kandidati: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // opcijsko: preprosto preskoči
    end;

    Raw := F.Value; // Variant; upošteva 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 po pretvorbi je napaka (pogosteje, kot se zdi)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Napaka preslikave: %s je zahtevan, vendar je vrednost NULL po pretvorbi. Polje %s (%s), surova vrednost=%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('Napaka preslikave pri %s iz polja %s (%s), surova vrednost=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Pretvorniki }

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);
    // sprejema tudi '0' kot niz
    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);

    // Namen strogo: noben 'Try' ne prikrije kakovosti podatkov.
    // Oblika se lahko razlikuje glede na legacy; po potrebi jo parametrizirajte tukaj preko TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Kako praktično uporabljati Mapper (brez RTTI, a vseeno elegantno)

Mapper kliče Assign(TargetMember, Value)-callback-funkcijo. To ohranja dodeljevanje eksplicitno (in s tem lažje za razhroščevanje) ter izogne RTTI-dostopom v hot-pathu. V praksi za vsak objekt/DTO (Data Transfer Object, torej prenosni objekt za podatke) zgradite majhen „dodeljevalec“.

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;

Namen: Mapiranje je na enem mestu centralno opisano (Specs), vendar dodeljevanje ostane eksplicitno. V Legacy-situacijah je to pogosto boljša kompromisna odločitev kot popolnoma avtomatsko RTTI-mapiranje, saj takoj vidite, katera property je odvisna od katerih imen polj.

Predpogoji: Pristop pričakuje aktivno Dataset in trenutno pozicijo zapisa. Za uvoze v serijah iterirajte zunaj z while not DS.Eof do in pokličite MapCustomer za vsako vrstico.

Pasti: Bodite pozorni na VarToStr pri BLOB-ih ali memo-poljih; tam je smiselno uporabiti lastne konverterje. In: „Required“ pomeni tukaj po konverterju. Če C_TrimToNull required-polje nastavi na Null, je to namerno – kakovost podatkov je treba rešiti pri viru ali v procesu.

Varianten: Namesto ciljnih nizov lahko uporabite tudi Enum, da izključite tipkarske napake. Alternativno lahko Assign-funkcijo shranite za vsak Spec kot TProc<Variant>, s čimer odpravite Target-string povsem (malo več boilerplata, vendar še manj razredov napak).

Umestitev v arhitekturo: DAL/Repository, logging in obratovanje

V plastični arhitekturi (tipično: UI – Business – dostop do podatkov) to mapiranje sodi v plast za dostop do podatkov ali v repoziotorij. Pomembno je, da Dataset ni „posredovan naprej“: objekti/DTO-ji so stabilnejši vmesnik, zlasti če boste kasneje nadgrajevali REST-APIs ali izločali dele v C# Services.

Za obratovanje in podporo se izplača debug-hook OnDebug. Z njim lahko v testih ali pri reproducibilnih podpornih primerih protokolirate, katera polja so bila dejansko mapirana. V produktivnih sistemih naj bo to ciljno uporabljeno in izklopljivo, sicer bo beleženje predrago ali bo vsebovalo preveč podatkov.

Smiselna uporaba debug-hooka

  • Enotski testi: Preverite, ali določen SQL-stavek resnično vrne vsa zahtevana polja.
  • Diagnostika: Pri težavah pri strankah takoj vidite ‚polje ni bilo prisotno‘ ali ‚vrednosti ni bilo mogoče pretvoriti‘.
  • Faze migracije: Pri prehodu na nove Views/ime stolpcev lahko vzporedno vzdržujete sezname kandidatov, dokler vse ni preseljeno.

Kdaj ta pristop zataji (in kaj je potem bolje)

Prikazano mapiranje datasetov v objekte je učinkovito, kadar je vir podatkov nestabilen in vseeno potrebujete deterministično vedenje. Običajno odpove v dveh situacijah:

  • Zelo velike količine (npr. množični izvoz): pretvorbe Variant in iskanje po imenu polja lahko postanejo opazne. Takrat se izplača vnaprejšnje predpomnjenje indeksov polj za vsak SQL (npr. FieldByName enkrat na Dataset, ne za vsako vrstico).
  • Zelo veliko tipov DTO: Če napišete več sto mapperjev, bo ponavljajoča se koda težava. Takrat je lahko smiselna RTTI-podprt pristop z atributi – vendar le, če strogo nadzirate izpise za diagnostiko in konverterje.

Dober vmesni pristop je: razrešitev polj in pretvorbe kot tukaj (eksplizitno, toleranco na napake tam, kjer je potrebna), vendar z generirano kodo (npr. preko internih predlog) namesto ročno napisane.

Sklep: stabilnost skozi eksplicitna pravila – z jasnimi mejami uporabe

Pri legacy-datasetih z aliasi, neobveznimi stolpci in zgodovinsko interpretacijo NULL je mapiranje datasetov v objekte uspešno predvsem, če ostane eksplicitno in diagnostično. Načrt mapiranja iz seznamov kandidatov, zahtevano/neobvezno in konverterjev to doseže: postopoma lahko stabilizirate zapuščene rešitve, ne da bi takoj uvedli ORM ali bazo podatkov „naenkrat“ normalizirali.

Meje so pri ekstremni zmogljivosti in pri zelo številnih tipih – takrat potrebujete predpomnjenje ali samodejno generiranje kode. Za tipično poslovno programsko opremo z razvitimi procesi pa je pristop zanesljivo orodje za ponovno ločevanje dostopa do podatkov in modelov domene ter za izboljšanje vzdrževanja.

Če pri konkretnem legacy-mapiranju (FireDAC, Views, razrast joinov, Null-Semantik) potrebujete drugo mnenje ali zanesljivo ciljano arhitekturo, je naslednji korak običajno kratka analiza z reproducibilnimi primeri. Kontakt:

V strokovnem okolju imajo tudi Delphi Dataset Mapping in Legacy Delphi pomembno vlogo, ko morajo integracije, pretoki podatkov in nadaljnji razvoj čisto sodelovati.

Prediskutirajte projekt ali modernizacijski podvig z Net-Base.

Deli objavo

Deli ta prispevek neposredno

LinkedIn, X, XING, Facebook, WhatsApp in e-pošta so takoj na voljo. Za Instagram bomo neposredno pripravili povezavo in kratek opis.

E-pošta

Instagram se odpre v novem zavihku. Povezava in kratek opis se pred tem kopirata v odložišče.