Net-Base Magasin

10.05.2026

Datasett-til-objekt-mapping for uvanlige arvede strukturer: stabilt, feilsøkbart, uten ORM-magikk

Når legacy-datasett har utviklet seg over tid, bryter standardmapper ofte på alias-kolonner, typeblandinger og varierende join-strukturer. Dette kildeeksempelet viser et robust, feilsøkbart dataset-til-objekt-mapping i Delphi: med mapping-plan, konvertere, null-semantikk...

10.05.2026

I etablerte Delphi-systemer er Dataset-til-objekt-mapping sjelden det rene «ett felt = én property»-tilfellet. I skreddersydd bedriftsprogramvare møter du i stedet alias-kolonner fra views, join-resultater med dupliserte feltnavn, «tomme» verdier som 0 eller ' ', typede felter som i dag gir VARCHAR og i morgen INTEGER, og kolonner som avhengig av søkedialogen rett og slett ikke er med. Det er ofte her mange mapper bryter sammen: enten blir de for «magiske» (og dermed vanskelige å feilsøke), eller de er så strenge at et valgfritt felt stopper driften.

Dette kildekodeutdraget viser en pragmatisk mapper for Delphi, som bevisst ikke er et ORM, men som adresserer de viktigste legacy-kanttilfellene på en klar måte: entydig feltoppløsning, kontrollert konvertering, null-semantikk, valgfrie felter og etterprøvbare feilmeldinger. Den egner seg for Data-Access-Layer (DAL, altså et lag som kapsler datatilgang) eller repository-mønstre – og kan kombineres godt med BDE-avløsning med native tilkobling (Delphis datatilgangsbibliotek for mange DB-er).

Hvorfor standardmapping svikter for eldre strukturer

Noen typiske årsaker fra drift som man sjelden ser i et «rent» ny‑design:

  • Tvetydige feltnavn: Join gir ID fra flere tabeller; i datasettet heter det da ID, ID_1 eller er blitt omdøpt med SQL-alias.
  • Semantiske nuller: 0 betyr «ukjent», '1899-12-30' er «ikke en dato», ' ' er «ikke vedlikeholdt».
  • Varierende typer: En view caster ikke; driveren leverer ftWideString i stedet for ftInteger. Variant-konvertering blir en feilkilde.
  • Valgfrie kolonner: En søkedialog bruker avhengig av filter ulike SELECT-lister. Koden forventer imidlertid feltene «alltid».
  • Feilsøkingsevne: Når mapping forsvinner inn i RTTI, blir feilsporing i kundedata vanskelig (hvilket felt, hvilken verdi, hvilken type?).

Tilnærming: Mapping-plan i stedet for konvensjon, med kontrollert konvertering

Kjernen er en mapping‑plan: en liste med regler «Property X kommer fra felt A eller B, er valgfri/obligatorisk, bruker konverter Y». På den måten forblir mappingen deklarativ, men ikke «usynlig» slik den ofte er i mange ORM-mekanismer. I tillegg kan mapperen kaste et informativt unntak per felt, inklusive feltnavn, datatype og råverdi.

Viktig: Vi mapper bevisst fra TDataSet, ikke fra en konkret BDE-Ablosung mit nativer Anbindung-klasse. Dette gjør den kompatibel med TFDQuery, TClientDataSet eller eksterne komponenter.

Kildeeksempel: Feilsøkbart dataset-til-objekt-mapping for legacy-kolonner

Koden implementerer:

  • Feltoppløsning via en prioritetsliste (aliaser/fallbacks)
  • Håndtering av obligatoriske/valgfrie felter
  • Null-semantikk via konvertere (f.eks. 0 => Null)
  • Stabile feilmeldinger med kontekst
  • En debug-hook for å kunne gjenskape mapping-problemer i test eller i support‑tilfeller

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 mottar Variant og returnerer Variant (f.eks. Null, Integer, String, TDateTime som 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: kaller setter for hver Spec. Ikke RTTI: eksplisitt tildeling er lettere å feilsøke.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;

// Hjelpekonvertere
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
// Bruker FindField i stedet for FieldByName: mulig uten å kaste exception
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 er ikke aktiv.‘);

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-feil: Påkrevd felt for %s ble ikke funnet. Kandidater: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valgfritt: bare hopp over
end;

Raw := F.Value; // Variant; tar hensyn til 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;

// Påkrevd: Null etter konvertering er en feil (vanligere enn 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-feil: %s er påkrevd, men verdien er NULL etter konvertering. Felt %s (%s), råverdi=%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-feil ved %s fra felt %s (%s), råverdi=%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);

// Bevisst strikt: ingen ‚Try‘ som skjuler datakvalitet.
// Format kan variere avhengig av legacy; eventuelt parametriser her via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;

end.

Hvordan bruke mapperen i praksis (uten RTTI, men likevel elegant)

Mapperen kaller en Assign(TargetMember, Value)-callback-funksjon. Det holder tilordningen eksplisitt (og dermed godt debuggbart) og unngår RTTI-tilgang i hot-path. I praksis bygger du per objekt/DTO (Data Transfer Object, altså et transportobjekt for data) en liten „tilordner“.

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: Mappingen er sentralt beskrevet ett sted (Specs), men tilordningen forblir eksplisitt. I Legacy-situasjoner er dette som regel en bedre avveining enn et fullt automatisk RTTI-mapping, fordi du umiddelbart ser hvilken Property som avhenger av hvilke feltnavn.

Forutsetninger: Tilnærmingen forutsetter et aktivt dataset og en aktuell record-posisjon. For batch-importer itererer du utenfor over while not DS.Eof do og kaller MapCustomer per rad.

Fallgruver: Vær oppmerksom på VarToStr for BLOBs eller memo-felt; der bør du bruke egne konvertere. Og: „Required“ betyr her etter konverter. Hvis C_TrimToNull setter et Required-felt til null, er det tilsiktet – datakvalitet må da håndteres ved kilden eller i prosessen.

Varianter: I stedet for string-targets kan du også bruke en enum for å utelukke tastefeil. Alternativt kan Assign-funksjonen per Spec lagres som TProc<Variant>, da forsvinner Target-strengen helt (litt mer boilerplate, men færre feiltyper).

Plassering i arkitekturen: DAL/Repository, Logging og drift

I en lagdelt arkitektur (typisk: UI – Business – Data-tilgang) hører dette mappinget til i data-tilgangslaget eller i et repository. Viktig er at datasetet ikke „durchgereicht“ wird: objekter/DTOs er det mer stabile grensesnittet, spesielt hvis du senere ettermonterer REST-APIer eller legger ut deler i C# Services.

For drift og support lønner Debug-hooken OnDebug seg. Du kan med den i tester eller ved reproduserbare supporttilfeller loggføre hvilke felt som faktisk ble mappet. I produksjonssystemer bør dette være målrettet og kunne slås av, ellers blir logging for kostbart eller for datatungt.

Fornuftig bruk av Debug-hook

  • Enhetstester: Sjekk om en gitt SQL-setning faktisk leverer alle påkrevde felt.
  • Diagnose: Ved kundeproblemer ser du med en gang «feltet manglet» vs. «verdien kunne ikke konverteres».
  • Migrasjonsfaser: Ved endring av views/kolonnenavn kan du vedlikeholde kandidatlister parallelt inntil alt er flyttet.

Når denne tilnærmingen svikter (og hva som er bedre da)

Det viste dataset-til-objekt-mappinget er robust når datakilden er ustabil og du likevel trenger deterministisk oppførsel. Det svikter typisk i to situasjoner:

  • Veldig store mengder (f.eks. masseeksport): Variant-konvertering og å søke per feltnavn kan bli merkbart. Da lønner det seg med forhåndsberegnet feltindeks-caching per SQL (f.eks. FieldByName én gang per dataset, ikke per rad).
  • Svært mange DTO-typer: Hvis du skriver hundrevis av mapper blir boilerplate et problem. Da kan en RTTI-basert tilnærming med attributter være fornuftig – men bare hvis du kontrollerer debug-utskrifter og konverteringsrutiner strengt.

En god mellomløsning er: feltoppløsning og konvertering som her (eksplisitt, feiltolerant der det trengs), men med generert kode (f.eks. via interne maler) i stedet for «håndskrevet».

Konklusjon: Stabilitet gjennom eksplisitte regler – med klare bruksgrenser

For legacy-datasett med aliaser, valgfrie kolonner og historisk null-semantikk er dataset-til-objekt-mapping spesielt vellykket når det forblir eksplisitt og diagnosebart. Mapping-planen bestående av kandidatlister, påkrevde/valgfrie og konverteringsrutiner oppnår nettopp dette: du kan stabilisere arv trinnvis uten å innføre et ORM umiddelbart eller å normalisere databasen «på en gang».

Grensene går ved ekstrem ytelse og ved svært mange typer – da trenger du caching eller automatisert kodegenerering. For typisk forretningsprogramvare med modne prosesser er tilnærmingen likevel et pålitelig virkemiddel for å holde dataadgang og domenemodeller løst koblet og vedlikeholdbare.

Hvis du ved et konkret legacy-mapping (FireDAC, views, join-villvokst, null-semantikk) trenger en annen mening eller en pålitelig målarkitektur, er neste steg vanligvis en kort analyse med reproduserbare eksempler. Kontakt:

I faglige sammenhenger spiller også Delphi dataset-mapping og legacy Delphi en viktig rolle når integrasjoner, dataflyt og videreutvikling må fungere sammen på en ryddig måte.

Diskutere prosjekt eller moderniseringsprosjekt med Net-Base.

Del innlegg

Del dette innlegget direkte

LinkedIn, X, XING, Facebook, WhatsApp og e‑post er umiddelbart tilgjengelig. For Instagram forbereder vi lenke og kort tekst umiddelbart.

E-post

Instagram åpnes i en ny fane. Lenken og kortteksten kopieres først til utklippstavlen.