Net-Base Magasin

10.05.2026

Dataset-till-objekt-mappning för ovanliga ärvda strukturer: stabil, debuggbar, utan ORM-magi

När legacy-datasets vuxit fram historiskt misslyckas standardmappare ofta vid aliaskolumner, typblandningar och skiftande joinstrukturer. Detta källkodsexempel visar en robust, felsökbar dataset‑till‑objekt‑mappning i Delphi: med mappningsplan, konverterare, nullsemantik...

10.05.2026

I etablerade Delphi-system är dataset-till-objekt-mappning sällan det rena „ett fält = en property“-fallet. I skräddarsydd företagsmjukvara stöter du istället på aliaskolumner från vyer, join-resultat med dubbla fältnamn, „tomma“ värden som 0 eller ' ', typade fält som idag levererar VARCHAR och i morgon INTEGER, och kolumner som helt enkelt saknas beroende på sökdialog. Det är exakt där många mapper fallerar: antingen blir de för „magiska“ (och därmed svåra att debugga), eller så är de så strikta att redan ett valfritt fält stoppar driften.

Denna source-schnipsel visar en pragmatisk mapper för Delphi, som medvetet inte är ett ORM, men som rent adresserar de viktigaste legacy-kantfallen: entydig fältupplösning, kontrollerad konvertering, null-semantik, valfria fält och spårbara felmeddelanden. Den lämpar sig för Data-Access-Layer (DAL, alltså ett lager som kapslar in dataåtkomst) eller repositorymönster – och kan kombineras väl med BDE-ersättning med nativen anslutning (Delphis dataåtkomstbibliotek för många DBs).

Varför standardmappning misslyckas med äldre strukturer

Några typiska orsaker från drift som man sällan ser vid ett „rent“ nykonstruktion:

  • Tvetydiga fältnamn: En join returnerar ID från flera tabeller; i datasetet heter det då ID, ID_1 eller är omdöpt via SQL-alias.
  • Semantiska nulls: 0 betyder „okänt“, '1899-12-30' är „inget datum“, ' ' är „ej ifyllt“.
  • Varierande typer: En view castar inte; drivrutinen levererar ftWideString istället för ftInteger. Variant-konvertering blir en felkälla.
  • Valfria kolumner: En sökdialog använder beroende på filter olika SELECT-listor. Koden förväntar sig dock fälten „alltid“.
  • Debuggbarhet: Om mappningen försvinner in i RTTI blir felsökning mot kunddata svår (vilket fält, vilket värde, vilken typ?).

Ansats: Mappningsplan istället för konvention, med kontrollerad konvertering

Kärnan är en mappningsplan: en lista med regler „Property X kommer från fält A eller B, är valfri/obligatorisk, använder konverter Y“. På så sätt förblir mappningen deklarativ men inte „osynlig“ som hos många ORM-mekanismer. Dessutom kan mappen per fält kasta ett informativt undantag, inklusive fältnamn, datatyp och råvärde.

Viktigt: Vi mappar medvetet från TDataSet, inte från en konkret BDE-Ablosung mit nativer Anbindung-klass. Det håller kompatibiliteten med TFDQuery, TClientDataSet eller även tredjepartskomponenter.

Source-Schnipsel: Debuggbar Dataset-zu-Objekt Mapping för Legacy-Spalten

Koden implementerar:

  • Fältupplösning via en prioriteringslista (Aliases/Fallbacks)
  • Hantering av obligatoriska/valfria fält
  • Null-semantik över konverterare (z. B. 0 => Null)
  • Stabila felmeddelanden med kontext
  • En debug-hook för att kunna spåra mappningsproblem i test eller vid supportfall
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 och returnerar Variant (t.ex. 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: anropar setter för varje spec. Ingen RTTI: explicit tilldelning är lättare att debugga.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Hjälp-konverterare
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
    // Använder FindField istället för FieldByName: tillåter att fält saknas utan undantag
    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 är inte 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('Mappingfel: Required-fält för %s hittades inte. Kandidater: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // valfritt: hoppa över
    end;

    Raw := F.Value; // Variant; tar hänsyn till 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 är ett fel (vanligare än 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('Mappingfel: %s är Required, men värdet är NULL efter konvertering. Fält %s (%s), råvärde=%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('Mappingfel vid %s från fält %s (%s), råvärde=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Konverterare }

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);
    // tolererar även '0' som sträng
    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);

    // Medvetet strikt: ingen "Try" som tystar datakvalitet.
    // Formatet kan variera beroende på legacy; eventuellt parametriseras här via TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Hur man använder Mapper i praktiken (utan RTTI, men ändå elegant)

Mappern anropar en Assign(TargetMember, Value)-callback-funktion. Det håller tilldelningen explicit (och därmed väl debuggbart) och undviker RTTI-åtkomster i hot-path. I praktiken bygger du per objekt/DTO (Data Transfer Object, alltså ett transportobjekt för data) en liten „tilldelare“.

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;

Syfte: Mappningen beskrivs centralt på ett ställe (Specs), men tilldelningen förblir explicit. I legacy-situationer är detta oftast det bättre trade-off-beslutet än ett fullautomatiskt RTTI-mapping, eftersom du omedelbart ser vilken Property som beror på vilka fältnamn.

Randvillkor: Metoden förutsätter ett aktivt dataset och en aktuell record-position. För batchimporter itererar du utanför med while not DS.Eof do och kallar MapCustomer för varje rad.

Fallgropar: Var försiktig med VarToStr vid BLOBs eller memo-fält; där bör du använda egna konverterare. Och: „Required“ betyder här efter konverteraren. Om C_TrimToNull sätter ett Required-fält till null är det avsiktligt – datakvalitet måste då hanteras vid källan eller i processen.

Varianter: Istället för string-targets kan du också använda en Enum för att utesluta skrivfel. Alternativt kan Assign-funktionen per Spec sparas som TProc<Variant>, då försvinner Target-strängen helt (lite mer boilerplate, men färre felkällor).

Inplacering i arkitekturen: DAL/Repository, Logging och drift

I en lagerarkitektur (typiskt: UI – Business – dataåtkomst) hör denna mappning hemma i dataåtkomstlagret eller i ett Repository. Viktigt är att datasetet inte „passeras vidare“: Objekt/DTOs är det stabilare gränssnittet, särskilt om du senare adderar REST-API:er eller lägger ut delar i C# Services.

För drift och support är debug-hooken OnDebug värdefull. Med den kan ni i tester eller vid reproducerbara supportfall logga vilka fält som faktiskt mappats. I produktiva system bör det vara riktat och avstängbart, annars blir loggningen för dyr eller för datatät.

Använd Debug-Hooken effektivt

  • Unit-Tester: Kontrollera om en given SQL-sats verkligen returnerar alla Required-fält.
  • Diagnostik: Vid kundproblem ser ni omedelbart „fältet saknades“ kontra „värdet kunde inte konverteras“.
  • Migrationsfaser: Vid övergång av views/kolumnnamn kan ni underhålla kandidatlistor parallellt tills allt är migrerat.

När detta angreppssätt brister (och vad som då är bättre)

Det visade dataset-till-objekt-mappningen är kraftfull när datakällan är ostadig och ni ändå behöver deterministiskt beteende. Den sviker typiskt i två situationer:

  • Mycket stora mängder (t.ex. massexport): Variant-konvertering och sökningar per fältnamn kan bli märkbara. Då lönar sig ett förberäknat fältindex-caching per SQL (t.ex. FieldByName en gång per dataset, inte per rad).
  • Mycket många DTO-typer: Om ni skriver hundratals mapper blir boilerplate ett problem. Då kan ett RTTI-baserat tillvägagångssätt med attribut vara lämpligt – men endast om ni strikt kontrollerar debug-utskrifter och konverterare.

En bra mellanväg är: fältupplösning och konvertering som här (explicit, felförlåtande där det behövs), men med genererad kod (t.ex. via interna mallar) istället för „handskriven“.

Slutsats: Stabilitet genom explicita regler – med tydliga användningsgränser

Vid legacy-datasets med alias, valfria kolumner och historisk null-semantik är dataset-till-objekt-mappning framför allt framgångsrik när den förblir explicit och diagnostisk. Mapping-planen bestående av kandidatlistor, Required/Optional och konverterare uppnår precis det: Ni kan stegvis stabilisera befintliga restproblem och historiska särfall, utan att omedelbart införa ett ORM eller normalisera databasen „på en gång“.

Gränserna ligger vid extrem prestanda och mycket många typer – då behöver ni caching eller automatiserad kodgenerering. För typisk affärsprogramvara med växande processer är tillvägagångssättet däremot ett pålitligt verktyg för att åter koppla isär dataåtkomst och domänmodeller och göra dem underhållbara.

Om ni i ett konkret legacy-mappning (FireDAC, views, join-vildvuxnad, null-semantik) behöver en andra åsikt eller en trovärdig målarkitektur, är nästa steg vanligtvis en kort analys med reproducerbara exempel. Kontakt:

I det domänmässiga sammanhanget spelar också Delphi dataset-mappning och legacy Delphi en viktig roll, när integrationer, dataflöden och vidareutveckling måste samspela på ett rent sätt.

Diskutera projekt eller moderniseringsinitiativ med Net-Base.

Dela inlägg

Dela det här inlägget direkt

LinkedIn, X, XING, Facebook, WhatsApp och e‑post är omedelbart tillgängliga. För Instagram förbereder vi länken och en kort text direkt.

E-post

Instagram öppnas i en ny flik. Länken och korttexten kopieras till urklipp först.