Net-Base Magasin

10.05.2026

Dataset-til-objekt-mapping for usædvanlige ældre strukturer: stabil, debugbar, uden ORM-magi

Når legacy-datasæt er vokset historisk, går standardmapper ofte i stå ved aliaskolonner, blandede datatyper og skiftende join-strukturer. Dette kildekodeudsnit viser et robust, debugbart dataset-til-objekt-mapping i Delphi: med mapping-plan, konvertere, null-semantik...

10.05.2026

I voksede Delphi-systemer er Dataset-til-objekt Mapping sjældent det rene „et felt = en property“-tilfælde. I individuel virksomhedssoftware støder man i stedet på alias-kolonner fra views, join-resultater med dublerede feltnavne, „tomme“ værdier som 0 eller ' ', typede felter der i dag leverer VARCHAR og i morgen INTEGER, samt kolonner der afhængigt af søgedialogen simpelthen ikke er med. Netop dér fejler mange mapper: Enten bliver de for „magiske“ (og dermed svære at debugge), eller de er så strikte, at et valgfrit felt stopper driften.

Denne source-snip viser en pragmatisk mapper for Delphi, som bevidst ikke er et ORM, men som rent adresserer de vigtigste legacy-kanttilfælde: entydig feltopløsning, kontrolleret konvertering, null-semantik, valgfrie felter og efterviselige fejlmeddelelser. Den egner sig til Data-Access-Layer (DAL, altså et lag der kapsler dataadgang) eller repository-patterns – og kan kombineres godt med BDE-erstatning med native tilslutning (Delphis dataadgangsbibliotek for mange DBs).

Hvorfor standard-mapping fejler ved ældre strukturer

Et par typiske årsager fra driften, som man sjældent ser i et „rent“ nydesign:

  • Tvetydige feltnavne: Join returnerer ID fra flere tabeller; i datasættet hedder det så ID, ID_1 eller er omdøbt via et SQL-alias.
  • Semantiske nuller: 0 betyder „ukendt“, '1899-12-30' er „intet dato“, ' ' er „ikke udfyldt“.
  • Svingende typer: Et view caster ikke; driveren leverer ftWideString i stedet for ftInteger. Variant-konvertering bliver en fejlkilde.
  • Valgfrie kolonner: En søgedialog bruger afhængigt af filtre forskellige SELECT-lister. Koden forventer dog felter „altid“.
  • Debuggability: Når mapping forsvinder ind i RTTI, bliver fejlsøgning på kundedata vanskelig (hvilket felt, hvilken værdi, hvilken type?).

Tilgang: Mapping-plan frem for konvention, med kontrolleret konvertering

Kernen er en Mapping-Plan: en liste af regler „Property X kommer fra felt A eller B, er optional/required, bruger konverter Y“. Dermed forbliver mappingen deklarativ, men ikke „usynlig“ som i mange ORM-mekanismer. Derudover kan mapperen per felt kaste en sigende undtagelse, inklusive feltnavn, datatypen og råværdien.

Vigtigt: Vi mapper bevidst fra TDataSet, ikke fra en konkret BDE-Ablosung mit nativer Anbindung-klasse. Dermed forbliver det kompatibelt med TFDQuery, TClientDataSet eller også tredjepartskomponenter.

Source-Schnipsel: Debugbart Dataset-til-Objekt Mapping for legacy-kolonner

Koden implementerer:

  • Feltopløsning via en prioriteringsliste (aliases/fallbacks)
  • Required/Optional-håndtering
  • Null-semantik via konvertere (f.eks. 0 => Null)
  • Stabile fejlmeddelelser med kontekst
  • En debug-hook for at kunne efterspore mapping-problemer i test eller i support-sager

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 modtager Variant og returnerer Variant (fx Null, Integer, String, TDateTime som 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: kalder setter for hver spec. Ikke RTTI: eksplicit tildeling er lettere at debugge.
procedure MapOne(DS: TDataSet; const Specs: TArray;
const Assign: TProc);
end;

// Hjælpekonvertere
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
// Brug FindField i stedet for FieldByName: valgbar uden 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 er ikke aktivt.‘);

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-fejl: Required-feltet for %s blev ikke fundet. Kandidater: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valgfri: bare spring over
end;

Raw := F.Value; // Variant; håndterer 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 efter konvertering er en fejl (hyppigere end man tror)
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-fejl: %s er Required, men værdien er NULL efter konvertering. Felt %s (%s), råværdi=%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-fejl ved %s fra felt %s (%s), råværdi=%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);
// tolererer også ‚0‘ som streng
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);

// Bevidst strikt: ingen ‚Try‘ der skjuler datakvalitet.
// Formatet kan variere afhængigt af legacy; eventuelt parameterisere her via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;

end.

Hvordan man bruger mapperen i praksis (uden RTTI, men alligevel elegant)

Mapperen kalder en Assign(TargetMember, Value)-callbackfunktion. Det holder tildelingen eksplicit (og dermed godt debugbar) og undgår RTTI-adgang i Hot-Path. I praksis bygger man per objekt/DTO (Data Transfer Object, altså et transportobjekt for data) en lille „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;

Formål: Mappinget er beskrevet centralt ét sted (Specs), men tildelingen forbliver eksplicit. I Legacy-situationer er det som regel den bedre trade-off end et fuldautomatiseret RTTI-mapping, fordi man straks kan se, hvilken property afhænger af hvilke feltnavne.

Forudsætninger: Tilgangen forudsætter et aktivt Dataset og en aktuel record-position. For batch-importer itererer man udenom over while not DS.Eof do og kalder MapCustomer for hver række.

Faldgruber: Vær opmærksom på VarToStr ved BLOBs eller memo-felter; der bør man bruge egne konvertere. Og: „Required“ betyder her efter konverteren. Hvis C_TrimToNull sætter et Required-felt til Null, er det tilsigtet – datakvaliteten må da afklares ved kilden eller i processen.

Varianter: I stedet for string-targets kan man også bruge et Enum for at udelukke tastefejl. Alternativt kan Assign-funktionen pr. Spec gemmes som TProc<Variant>, så Target-strengen falder helt bort (en smule mere boilerplate, men til gengæld færre fejl).

Indplacering i arkitektur: DAL/Repository, Logging og Betrieb

I en lagdelt arkitektur (typisk: UI – Business – dataadgang) hører dette mapping til i dataadgangslaget eller i et Repository. Vigtigt er, at Dataset ikke „durchgereicht“ wird: Objekte/DTOs er det mere stabile interface, især hvis man senere eftermonterer REST-APIs eller udlægger dele til C# Services.

Til drift og support er Debug-Hook’en OnDebug nyttig. Den lader dig i tests eller ved reproducerbare supporttilfælde logge, hvilke felter der rent faktisk blev mappet. I produktive systemer bør det være målrettet og kunne deaktiveres, ellers bliver logging for dyrt eller for dataintensivt.

Fornuftig brug af Debug-Hook

  • Unit-Tests: Kontrollér, om en given SQL-forespørgsel virkelig leverer alle Required-Felder.
  • Diagnose: Ved kundeproblemer ser man straks «feltet fandtes ikke» vs. «værdien kunne ikke konverteres».
  • Migrationsphasen: Ved omstilling af views/kolonnenavne kan I vedligeholde kandidatlister parallelt, indtil alt er flyttet.

Hvornår denne tilgang når sine grænser (og hvad der så er bedre)

Det viste dataset-til-objekt-mapping er robust, når datakilden er ustabil, og man alligevel har brug for deterministisk adfærd. Det svigter typisk i to situationer:

  • Meget store mængder (f.eks. masseeksport): Variant-konvertering og søgning per feltnavn kan blive mærkbar. Så er et forudberegnet feltindeks-cache per SQL værd at overveje (f.eks. FieldByName én gang per Dataset, ikke per Row).
  • Et meget stort antal DTO-typer: Hvis man skriver hundreder af mapper, bliver boilerplate et problem. Så kan en RTTI-baseret tilgang med attributter være fornuftig – men kun, hvis man stramt kontrollerer debug-udskrifter og konvertere.

Et godt kompromis er: feltopløsning og konvertering som her (eksplicit, fejltolerant hvor nødvendigt), men med genereret kode (f.eks. via interne templates) i stedet for „håndskrevet“.

Konklusion: Stabilitet gennem eksplicitte regler – med klare anvendelsesgrænser

Ved Legacy-Datasets med aliases, valgfrie kolonner og historisk Null-Semantik er Dataset-zu-Objekt Mapping især succesfuldt, når det forbliver eksplicit og diagnosebart. Planen for mapping bestående af kandidatlister, Required/Optional og konvertere skaber præcis det: I kan stabilisere altlasten trinvis, uden straks at indføre et ORM eller normalisere databasen „på én gang“.

Grænserne ligger ved ekstrem ydeevne og ved et meget stort antal typer – så har man brug for caching eller automatiseret kodegenerering. For typisk forretningssoftware med etablerede processer er tilgangen dog et pålideligt redskab til at adskille dataadgang og domænemodeller og gøre dem vedligeholdelsesvenlige igen.

Hvis I ved et konkret Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) har brug for en second opinion eller en belastbar målarkitektur, er næste skridt som regel en kort analyse med reproducerbare eksempler. Kontakt:

I det faglige miljø spiller også Delphi Dataset Mapping og Legacy Delphi en vigtig rolle, når integrationer, dataflow og videreudvikling skal fungere sammen konsistent.

Drøft projekt eller moderniseringsforløb med Net-Base.

Del indlæg

Del dette indlæg direkte

LinkedIn, X, XING, Facebook, WhatsApp og e-mail er straks tilgængelige. Til Instagram forbereder vi link og kort tekst med det samme.

E-mail

Instagram åbner i en ny fane. Linket og kortteksten kopieres på forhånd til udklipsholderen.