Net-Base Časopis

10.05.2026

Mapiranje dataset-a u objekte za neobične naslijeđene strukture: stabilno, lako za debugiranje, bez ORM-magije

Kada su legacy dataseti historijski nastajali, standardni mapperi često zakažu zbog alias-kolona, miješanih tipova i promjenjivih struktura JOIN-ova. Ovaj isječak koda prikazuje robusno, lako za debugiranje mapiranje iz dataseta u objekt u Delphi: s planom mapiranja, konverterima i semantikom null-vrijednosti.

10.05.2026

U razvijenim Delphi-sistemima, mapiranje iz Dataset u objekt rijetko je čist slučaj „jedno polje = jedna property“. U individualnom poslovnom softveru umjesto toga nailazite na alias-kolone iz view‑ova, rezultate join‑a sa duplim imenima polja, „prazne“ vrijednosti kao 0 ili ' ', tipizirana polja koja danas vraćaju VARCHAR, a sutra INTEGER, i kolone koje, ovisno o dijalogu za pretragu, jednostavno nisu prisutne. Upravo tu mnogi mapperi zakažu: ili postanu „magični“ (i time teško debagabilni), ili su toliko striktni da već jedno opciono polje zaustavi rad.

Ovaj isječak koda prikazuje pragmatičan mapper za Delphi, koji namjerno nije ORM, ali obrađuje najvažnije legacy rubne slučajeve: jednoznačno razlučivanje polja, kontrolirane konverzije, semantiku null vrijednosti, opciona polja i razumljive poruke o greškama. Pogodan je za Data-Access-Layer (DAL, dakle sloj koji enkapsulira pristup podacima) ili Repository-patternima – i dobro se kombinuje sa BDE-zamjena s nativnom vezom (biblioteka za pristup podacima Delphi za mnoge DB).

Zašto standardno mapiranje ne uspijeva kod naslijeđenih struktura

Par tipičnih uzroka iz produkcije koje pri „čistom“ redizajnu rijetko srećete:

  • Višeznačna imena polja: Join vraća ID iz više tabela; u Datasetu se onda pojavljuje kao ID, ID_1 ili je preimenovano SQL-aliasom.
  • Semantičke nule: 0 znači „nepoznato“, '1899-12-30' znači „nema datuma“, ' ' znači „nije unešeno“.
  • Varirajući tipovi: View ne radi cast; driver vraća ftWideString umjesto ftInteger. Variant-konverzija postaje izvor grešaka.
  • Opcionalne kolone: Dijalog za pretragu koristi ovisno o filteru različite SELECT‑liste. Kod očekuje da su polja „uvijek“ prisutna.
  • Mogućnost otklanjanja grešaka: Kada mapiranje nestane u RTTI‑ju, otkrivanje grešaka na stvarnim podacima klijenta je teško (koje polje, koja vrijednost, koji tip?).

Pristup: plan mapiranja umjesto konvencije, uz kontroliranu konverziju

Suština je u planu mapiranja: lista pravila „property X dolazi iz polja A ili B, je opcionalno/obavezno, koristi konverter Y“. Time mapiranje ostaje deklarativno, ali nije „nevidljivo“ kao kod mnogih ORM mehanizama. Dodatno, mapper može po polju baciti jasnu iznimku s imenom polja, tipom i sirovom vrijednošću.

Važno: mi namjerno mapiramo iz TDataSet, a ne iz konkretne BDE-Ablosung mit nativer Anbindung-klase. Tako ostaje kompatibilno sa TFDQuery, TClientDataSet ili drugim komponentama.

Isječak koda: mapiranje iz Dataset u objekt koje se može debugirati za naslijeđene kolone

Kod implementira:

  • Razlučivanje polja preko liste prioriteta (aliasi/fallbacki)
  • Rukovanje obaveznim i opcionim poljima
  • Semantiku null vrijednosti preko konvertera (npr. 0 => Null)
  • Stabilne poruke o greškama s kontekstom
  • Debug-hook koji omogućava praćenje problema mapiranja u testu ili u slučaju podrške

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 erhält Variant und liefert Variant (z. B. Null, Integer, String, TDateTime als 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: ruft Setter für jede Spec auf. Kein RTTI: explizite Zuweisung ist besser debugbar.
procedure MapOne(DS: TDataSet; const Specs: TArray;
const Assign: TProc);
end;

// Hilfs-Konverter
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 statt FieldByName: optional möglich, ohne 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 nije aktivan.‘);

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(‚Greška pri mapiranju: Obavezno polje za %s nije pronađeno. Kandidati: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // optional: schlicht überspringen
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 nach Konverter ist ein Fehler (häufiger als man denkt)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Greška pri mapiranju: %s je obavezno, ali vrijednost je NULL nakon konverzije. Polje %s (%s), sirova vrijednost=%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(‚Greška pri mapiranju za %s iz polja %s (%s), sirova vrijednost=%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);
// toleriert auch ‚0‘ als String
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);

// Absichtlich strikt: kein „Try“ verschluckt Datenqualität.
// Format kann je nach Legacy variieren; ggf. hier über TFormatSettings parametrieren.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;

end.

Kako praktično koristiti Mapper (bez RTTI, ali ipak elegantno)

Mapper poziva Assign(TargetMember, Value)-callback-funkciju. To čini dodjelu eksplicitnom (i time lako za debugovanje) i izbjegava RTTI-pristupe u hot-pathu. U praksi za svaki objekat/DTO (Data Transfer Object, dakle objekt za prijenos podataka) pravite mali „dodjeljivač“.

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;

Svrha: Mapiranje je centralno opisano na jednom mjestu (Specs), ali dodjela ostaje eksplicitna. U Legacy situacijama to je obično bolji kompromis od potpuno automatskog RTTI-mappinga, jer odmah vidite koja svojstva zavise od kojih naziva polja.

Preduvjeti: Pristup očekuje aktivan Dataset i trenutnu poziciju zapisa. Za batch-importe iterirajte izvana pomoću while not DS.Eof do i pozivajte MapCustomer po retku.

Zamke: Obratite pažnju na VarToStr kod BLOB-ova ili memo-polja; tamo biste trebali koristiti vlastite konvertere. I: „Required“ znači ovdje nakon konvertera. Ako C_TrimToNull postavi required polje na Null, to je namjerno – kvaliteta podataka se tada mora rješavati u izvoru ili u procesu.

Varijante: Umjesto string-targeta možete koristiti i enum kako biste isključili tipfelere. Alternativno, Assign-funkciju možete po Specu spremiti kao TProc<Variant>, tada se Target-String potpuno uklanja (nešto više boilerplatea, ali još manje mogućih grešaka).

Položaj u arhitekturi: DAL/Repository, logiranje i operativni rad

U slojevitom arhitektonskom modelu (tipično: UI – Business – pristup podacima) ovo mapiranje pripada sloju pristupa podacima ili repository-ju. Važno je da se Dataset ne „prosljeđuje“: objekti/DTO-ovi su stabilniji interfejs, naročito ako kasnije nadograđujete REST-API-je ili izdvajate dijelove u C# Services.

Za operacije i podršku se isplati koristiti Debug-Hook OnDebug. Pomoću njega u testovima ili pri reproduciranim slučajevima podrške možete evidentirati koja su polja zapravo mapirana. U produkcijskim sistemima to treba biti ciljano i moguće isključiti, inače će logiranje postati preskupo ili previše podatkovno intenzivno.

Ispravno korištenje Debug-Hooka

  • Unit-Tests: Provjerite da li određeni SQL-upit zaista vraća sva obavezna polja.
  • Diagnose: Kod problema kod klijenata odmah vidite „polje nije postojalo“ nasuprot „vrijednost se nije mogla konvertirati“.
  • Faze migracije: Prilikom mijenjanja Views/imena kolona možete paralelno održavati liste kandidata dok sve ne bude premješteno.

Kada ovaj pristup zakaže (i šta je onda bolje)

Prikazani pristup mapiranja dataset-a u objekte je robustan kada je izvor podataka nestabilan, a potrebito vam je determinističko ponašanje. Tipično zakazuje u dvije situacije:

  • Veće količine (npr. masovni izvoz): Variant-konverzija i pretraživanje po imenu polja može postati primjetno. U tom slučaju se isplati unaprijed izračunato keširanje indeksa polja po SQL-upitu (npr. FieldByName jednom po datasetu, ne po redu).
  • Vrlo mnogo DTO-tipova: Ako pišete stotine mappera, pojaviće se puno repetitivnog (boilerplate) koda. Tada može biti smislen RTTI-baziran pristup s atributima — ali samo ako striktno kontrolirate debug-izlaze i konvertere.

Dobar kompromis je: rješavanje polja i konverzija kao ovdje (eksplicitno, tolerantno na greške gdje je potrebno), ali s generiranim kodom (npr. preko internih predložaka) umjesto ručno pisanog.

Zaključak: Stabilnost kroz eksplicitna pravila – s jasnim granicama primjene

Kod legacy-dataset-a s aliasima, opcionalnim kolonama i istorijskom NULL-semantikom, mapiranje dataset-a u objekte je naročito uspješno ako ostane eksplicitno i dijagnostički upotrebljivo. Plan mapiranja sastavljen od lista kandidata, obavezno/opcionalno i konvertera upravo to omogućava: možete postepeno stabilizovati naslijeđene probleme bez potrebe za uvođenjem ORM-a ili momentalnom normalizacijom baze podataka.

Ograničenja su kod ekstremnih zahtjeva za performansama i kod vrlo velikog broja tipova — tada trebate keširanje ili automatizovanu generaciju koda. Za tipičan poslovni softver sa razvijenim procesima, pristup je ipak pouzdan alat za ponovno razdvajanje pristupa podacima i domenskih modela te za poboljšanje održivosti.

Ako za konkretno legacy-mapiranje (FireDAC, Views, prekomjerni broj JOIN-ova, NULL-semantika) trebate drugo mišljenje ili pouzdanu ciljnu arhitekturu, sljedeći korak je obično kratka analiza s reproducibilnim primjerima. Kontakt:

U stručnom kontekstu također važnu ulogu igraju Delphi Dataset Mapping i Legacy Delphi, kada integracije, tokovi podataka i dalji razvoj moraju uredno funkcionisati zajedno.

Razgovarajte o projektu ili planu modernizacije s Net-Base.

Podijeli objavu

Ovu objavu direktno proslijediti

LinkedIn, X, XING, Facebook, WhatsApp i e-pošta su odmah dostupni. Za Instagram pripremamo link i kratak tekst.

E-pošta

Instagram se otvara u novom tabu. Link i kratak tekst se prethodno kopiraju u međuspremnik.