Net-Base Magasin

10.05.2026

Dataset-til-objekt-mapping for uvanlege eldre strukturar: stabil, feilsøkingsvennleg, utan ORM-magikk

Når legacy-datasett er historisk veksne, står standard-mapper ofte fast ved alias-kolonnar, typeblandingar og skiftande join-strukturar. Denne kodesnutten visar eit robust, feilsøkingsvenleg datasett-til-objekt-mapping i Delphi: med mapping-plan, konverterarar, null-semantikk...

10.05.2026

I vaksne Delphi-system er dataset-til-objekt-mapping sjeldan det reine «eit felt = ei property»-tilfellet. I skreddarsydd bedriftsprogramvare møter ein i staden alias-kolonnar frå Views, join-resultat med doble feltnamn, „tomme“ verdiar som 0 eller ' ', typa felt som i dag leverer VARCHAR og i morgon INTEGER, og kolonnar som avhengig av søkdialogen rett og slett ikkje er med. Det er nettopp her mange mapperar sviktar: Anten blir dei for «magiske» (og dermed vanskelege å feilsøke), eller så strenge at eit valfritt felt stoppar drifta.

Denne kodesnutten viser ein pragmatisk mapper for Delphi, som medvite ikkje er eit ORM, men som reint adresserer dei viktigaste legacy-kanttilfella: entydig feltoppløysing, kontrollert konvertering, null-semantikk, valfrie felt og etterprøvbare feilmeldingar. Han eignar seg for Data-Access-Layer (DAL, altså eit lag som kapslar datatilgang) eller repository-patterns – og kan godt kombinerast med BDE-erstatting med nativ tilkopling (Delphis datatilgangsbibliotek for mange DB-ar).

Kvifor standard-mapping feilar på eldre strukturar

Nokre typiske årsaker frå drifta som ein sjeldan ser ved «reint» nydesign:

  • Tvetydelege feltnamn: Join leverer ID frå fleire tabellar; i datasetet heiter det då ID, ID_1 eller er omdøypt via SQL-alias.
  • Semantiske nullar: 0 tyder «ukjend», '1899-12-30' er «ikkje ein dato», ' ' er «ikkje vedlikehalde».
  • Svingande typar: Ein View kastar ikkje; driveren leverer ftWideString i staden for ftInteger. Variant-konvertering blir ein feilkjelde.
  • Valfrie kolonnar: Ein søkdialog brukar avhengig av filter ulike SELECT-lister. Koden ventar likevel felt «alltid».
  • Debuggbarheit: Når mapping forsvinn inn i RTTI, blir feilsøking mot kundedata vanskeleg (kva for felt, kva verdi, kva type?).

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

Kjernen er ein mapping-plan: ei liste med reglar «Property X kjem frå felt A eller B, er valfri/obligatorisk, nyttar konverter Y». Slik held mappinget seg deklarativt, men ikkje «usynleg» som ved mange ORM-mekanismar. I tillegg kan mapperen per felt kaste eit tydeleg unntak, inkludert feltnamn, datatype og råverdi.

Viktig: Vi mapper medvite frå TDataSet, ikkje frå ei konkret BDE-Ablosung mit nativer Anbindung-klasse. Slik held det seg kompatibelt med TFDQuery, TClientDataSet eller også tredjepartskomponentar.

Kodesnutt: Debuggbart dataset-til-objekt-mapping for legacy-kolonnar

Koden implementerer:

  • Feltoppløysing via ein prioriteringsliste (Aliases/Fallbacks)
  • Handtering av påkrevde/valfrie felt
  • Null-semantikk via konverterar (t.d. 0 => Null)
  • Stabile feilmeldingar med kontekst
  • Eit debug-hook for å kunne etterprøve mapping-problem i test eller ved support
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 tar Variant og returnerer Variant (t.d. 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: kallar setter for kvar Spec. Ikkje RTTI: eksplisitt tilordning er lettare å feilsøkje.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Hjelpekonverterar
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 i staden for FieldByName: mogleg utan unntak
    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('Datasetet er ikkje 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-feil: Påkrevd felt for %s ikkje funne. Kandidatar: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // valfritt: berre hopp over
    end;

    Raw := F.Value; // Variant; tek omsyn 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;

      // Required: Null etter konvertering er ein feil (vanlegare enn ein trur)
      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 verdi 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 frå 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);

    // Med vilje strikt: ingen "Try" som slukkar datakvaliteten.
    // Formatet kan variere avhengig av legacy; eventuelt parametriser her via TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Korleis ein brukar Mapperen i praksis (utan RTTI, men framleis elegant)

Mapperen kallar ein Assign(TargetMember, Value)-callback-funksjon. Det held tilordninga eksplisitt (og dermed godt feilsøkbar) og unngår RTTI-tilgang i hot-pathen. I praksis byggjer ein for kvart objekt/DTO (Data Transfer Object, altså eit transportobjekt for data) ein liten «tilordnar».

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: Mappinga er beskriven samla (Specs), men tilordninga forblir eksplisitt. I legacy-situasjonar er dette som regel eit betre kompromiss enn eit fullautomatisk RTTI-mapping, fordi ein med ein gong ser kva Property som er avhengig av kva feltnamn.

Forutsetningar: Tilnærminga forutset eit aktivt Dataset og ein aktuell rekordposisjon. For batch-importar itererer ein utanfor over while not DS.Eof do og kallar MapCustomer per rad.

Fallgruver: Ver merksam på VarToStr ved BLOB-ar eller memo-felt; der bør ein bruke eigne konverterar. Og: „Required“ betyr her etter konverteren. Om C_TrimToNull set eit Required-felt til Null, er det meint slik – datakvaliteten må då avklarast ved kjelda eller i prosessen.

Variantar: I staden for String-Targets kan ein også bruke eit enum for å unngå skrivefeil. Alternativt kan Assign-funksjonen per Spec lagrast som TProc<Variant>, då fell Target-strengen heilt bort (litt meir boilerplate, men færre feiltypar).

Plassering i arkitekturen: DAL/Repository, Logging og drift

I ein lagdelt arkitektur (typisk: UI – Business – dataåtkomst) høyrer dette mappinget til i dataåtkomstlaget eller i eit Repository. Det er viktig at datasettet ikkje blir vidaregjeve: Objekt/DTO-ar er eit meir stabilt grensesnitt, særleg når ein seinare ettermonterer REST-APIar eller flyttar ut delar til C# tenester.

For drift og support løner Debug-Hooken OnDebug seg. Ein kan med han i testar eller ved reproducerbare supporttilfelle loggføre kva felt som faktisk vart mappa. I produksjonssystem bør dette vere målretta og mogleg å slå av, elles blir logging for dyrt eller for dataintensivt.

Fornuftig bruk av Debug-Hook

  • Unit-Tests: Sjekke om eit bestemt SQL-Statement verkeleg leverer alle Required-Felder.
  • Diagnose: Ved kundespørsmål ser ein med ein gong «feltet fanst ikkje» vs. «verdi kunne ikkje konverterast».
  • Migrasjonsphaser: Ved omlegging av Views/kolonnenamn kan ein halde kandidatlister parallelt til alt er flytta.

Når denne tilnærminga sviktar (og kva som då er betre)

Det viste Dataset-til-objekt Mappinget er robust når datakjelda er ustabil og ein likevel treng deterministisk åtferd. Det sviktar vanlegvis i to situasjonar:

  • Svært store mengder (t.d. masseeksport): Variant-konvertering og å søkje per feltnamn kan bli merkbart. Då løner eit førehandskalkulert feltindeks-caching per SQL seg (t.d. FieldByName ein gong per Dataset, ikkje per Row).
  • Svært mange DTO-Typen: Når ein skriv hundrevis av Mapper, blir boilerplate eit tema. Då kan ein RTTI-basert tilnærming med attributt vere hensiktsmessig – men berre viss ein strikt kontrollerer debug-utgåver og konverterarane.

Eit godt mellomveg er: Feltoppløysing og konvertering som her (eksplisitt, feiltolerant der nødvendig), men med generert kode (t.d. gjennom interne malar) i staden for «handskriven».

Konklusjon: Stabilitet gjennom eksplisitte reglar – med klare bruksgrenser

Ved Legacy-Datasets med Aliases, valfrie kolonnar og historisk Null-Semantik lykkast Dataset-til-objekt Mapping først og fremst når det held seg eksplisitt og diagnosemogleg. Planen for mapping med kandidatlister, Required/Optional og konverterarar gir nett dette: Ein kan stabilisere arv trinnvis, utan å innføre eit ORM eller normalisere databasen «på ein gong».

Grensene går ved ekstrem ytelse og ved svært mange typar – då treng ein Caching eller automatisk kodeframstilling. For typisk Business-Software med vaksne prosessar er tilnærminga likevel eit påliteleg verkemiddel for å få datatilgang og Domänenmodelle att skilde og vedlikehaldne.

Dersom du i eit konkret Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) treng ei annan vurdering eller ei påliteleg målarkitektur, er neste steg vanlegvis ei kort analyse med reproduserbare døme. Kontakt:

I det faglege miljøet spelar også Delphi Dataset Mapping og Legacy Delphi ei viktig rolle, når integrasjonar, dataflyter og vidareutvikling må spele godt saman.

Drøft prosjekt eller moderniseringsprosjekt med Net-Base.

Del innlegg

Del dette innlegget direkte

LinkedIn, X, XING, Facebook, WhatsApp og e-post er tilgjengelege med ein gong. For Instagram klargjer vi lenke og kort tekst med det same.

E-post

Instagram opnar i ein ny fane. Lenkje og kort tekst blir kopiert til utklippstavla på førehand.