Net-Base Revista

10.05.2026

Mapeig de dataset a objecte per a estructures antigues insòlites: estable, depurable, sense màgia ORM

Quan els datasets legacy han evolucionat històricament, els mapejadors estàndard sovint topen amb columnes amb àlies, mescles de tipus i estructures de JOIN canviants. Aquest fragment de codi mostra un mapeig robust i fàcilment depurable de dataset a objecte en Delphi: amb pla de mapeig, convertidors, semàntica de null...

10.05.2026

En sistemes Delphi evolucionats, el mapeig de Dataset a objecte rarament és el cas net de «un camp = una propietat». En software empresarial a mida, en canvi, trobareu columnes àlies provinents de vistes, resultats de join amb noms de camp duplicats, valors «buits» com a 0 o ' ', camps tipats que avui retornen VARCHAR i demà INTEGER, i columnes que segons el diàleg de cerca simplement no apareixen. És precisament aquí on fallen molts mapejos: o bé es tornen massa «màgics» (i per tant difícils de depurar), o bé són tan estrictes que un camp opcional ja atura l’execució.

Aquest fragment de codi mostra un mapeig pragmàtic per a Delphi, que conscienciosament no és un ORM, però afronta netament els casos límit heretats més rellevants: resolució inequívoca de camps, conversió controlada, semàntica de nul, camps opcionals i missatges d’error traçables. És adequat per a Data-Access-Layer (DAL, és a dir, una capa que encapsula l’accés a dades) o Repository-Patterns – i es pot combinar bé amb BDE-substitució amb connexió nativa (la biblioteca d’accés a dades de Delphi per a moltes DBs).

Per què falla el mapeig estàndard en estructures heretades

Algunes causes típiques que es veuen en operació i que en un redisseny «net» rarament apareixen:

  • Noms de camp amb ambigüitat: un join retorna ID de diverses taules; en el Dataset apareix com ID, ID_1 o ha estat reanomenat via un alias SQL.
  • Nulls semàntics: 0 vol dir «desconegut», '1899-12-30' és «cap data», ' ' és «no informat».
  • Tipatges inconsistents: una vista no fa cast; el driver lliura ftWideString en lloc de ftInteger. La conversió de Variant esdevé una font d’errors.
  • Columnes opcionals: un diàleg de cerca usa llistes SELECT diferents segons els filtres. El codi, però, espera que els camps existeixin «sempre».
  • Capacitat de depuració: quan el mapeig desapareix en RTTI, la cerca de l’error amb dades reals del client és difícil (quin camp, quin valor, quin tipus?).

Enfoc: pla de mapeig en lloc de convenció, amb conversió controlada

El nucli és un pla de mapeig: una llista de regles «la propietat X prové del camp A o B, és opcional/obligatòria, utilitza el convertidor Y». Això manté el mapeig declaratiu, però no «invisible» com passa en molts mecanismes ORM. A més, el mapper pot llançar per camp una excepció informativa, incloent nom de camp, tipus de dada i valor cru.

Important: fem el mapeig expressament des de TDataSet, no des d’una classe BDE-Ablosung mit nativer Anbindung concreta. Això el manté compatible amb TFDQuery, TClientDataSet o components de tercers.

Fragment de codi: mapeig depurable de Dataset a objecte per a columnes heretades

El codi implementa:

  • Resolució de camps mitjançant una llista de prioritat (àlies/fallbacks)
  • Gestió required/optional
  • Semàntica de nul mitjançant convertidors (p. ex. 0 => Null)
  • Missatges d’error estables amb context
  • Un hook de depuració per tal de reproduir problemes de mapeig en proves o en atenció al client
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 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<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: ruft Setter für jede Spec auf. Kein RTTI: explizite Zuweisung ist besser debugbar.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  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<string>): 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<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 ist nicht 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-Fehler: Required-Feld für %s nicht gefunden. Kandidaten: [%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('Mapping-Fehler: %s ist Required, aber Wert ist NULL nach Konvertierung. Feld %s (%s), Rohwert=%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-Fehler bei %s aus Feld %s (%s), Rohwert=%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.

Com utilitzar el Mapper a la pràctica (sense RTTI, però de forma elegant)

El Mapper invoca una funció de callback Assign(TargetMember, Value). Això fa que l’assignació sigui explícita (i per tant fàcil de depurar) i evita accessos RTTI en el hot-path. A la pràctica, creeu per cada objecte/DTO (Data Transfer Object, és a dir, un objecte de transport de dades) un petit «assignador».

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;

Propòsit: El mapping està descrit de manera centralitzada en un sol lloc (Specs), però l’assignació resta explícita. En situacions Legacy sovint és la millor decisió de compromís en comparació amb un mapeig RTTI completament automàtic, perquè veieu immediatament quina propietat depèn de quins noms de camp.

Condicions marc: L’enfocament pressuposa un Dataset actiu i una posició de registre actual. Per a imports per lots, recorreu externament amb while not DS.Eof do i crideu MapCustomer per cada fila.

Punts a tenir en compte: Atenció a l’ús de VarToStr amb BLOBs o camps memo; en aquests casos hauríeu d’utilitzar convertidors propis. I: «Required» vol dir aquí després del convertidor. Si C_TrimToNull estableix un camp Required a Null, és intencionat — la qualitat de les dades cal resoldre-la a la font o en el procés.

Variants: En lloc d’utilitzar targets de tipus string també podeu fer servir un enum per excloure errors d’escriptura. Alternativament, la funció Assign es pot emmagatzemar per cada Spec com a TProc<Variant>, de manera que el string Target desapareix completament (una mica més de boilerplate, però encara menys tipologia d’errors).

Integració en l’arquitectura: DAL/Repository, Logging i explotació

En una arquitectura per capes (típic: UI – lògica de negoci – accés a dades) aquest mapping pertany a la capa d’accés a dades o a un repository. És important que el Dataset no es «repassi»: els objectes/DTOs són la interfície més estable, especialment si més endavant cal afegir REST-APIs o externalitzar parts en C# Services.

Per a l’explotació i el suport convé el Debug-Hook OnDebug. Amb aquest podeu registrar en proves o en casos de suport reproduïbles quins camps s’han mapejat realment. En sistemes productius això hauria de ser selectiu i desactivable; altrament el logging esdevé massa car o massa pesat en dades.

Ús adequat del Debug-Hook

  • Unit-Tests: Comprovar si una instrucció SQL concreta retorna realment tots els Required-Felder.
  • Diagnosi: Davant problemes de clients veureu immediatament «camp no present» vs. «no s’ha pogut convertir el valor».
  • Fases de migració: En canviar Views/nom de columnes podeu mantenir llistes de candidats en paral·lel fins que tot estigui migrat.

Quan aquest enfocament falla (i què cal fer en aquest cas)

El Dataset-zu-Objekt Mapping mostrat és robust quan la font de dades és inestable i necessiteu un comportament determinista. Normalment falla en dues situacions:

  • Quantitats molt grans (p. ex. exportació massiva): la conversió de Variant i la cerca per nom de camp poden fer-se notables. Aleshores convé un cache d’índexs de camps precomputat per cada SQL (p. ex. FieldByName una sola vegada per Dataset, no per Row).
  • Molts tipus DTO: Si escriviu centenars de mapper, la boilerplate esdevé un problema. Aleshores pot tenir sentit un enfocament basat en RTTI amb atributs, però només si controleu estrictament les sortides de depuració i els convertidors.

Una bona solució intermèdia és: resolució de camps i conversió com aquí (explícit, tolerants a errors on calgui), però amb codi generat (p. ex. mitjançant plantilles internes) en lloc de „escrit a mà“.

Conclusió: Estabilitat mitjançant regles explícites – amb límits d’ús clars

En datasets legacy amb aliases, columnes opcionals i semàntica històrica de nulls, el Dataset-zu-Objekt Mapping té èxit sobretot quan es manté explícit i apte per al diagnòstic. El pla de mapeig basat en llistes de candidats, Required/Optional i convertidors fa exactament això: podeu estabilitzar les herències històriques pas a pas sense haver d’introduir immediatament un ORM o normalitzar la base de dades „d’un sol cop“.

Les limitacions apareixen amb requisits d’extrema rendiment i amb un nombre molt elevat de tipus – llavors necessitareu caching o generació automàtica de codi. Per a la típica Business-Software amb processos consolidats, l’enfocament, però, és una palanca fiable per desacoblar i fer mantenible l’accés a dades i els models de domini.

Si en un mapping legacy concret (FireDAC, Views, proliferació de Join, semàntica de nulls) necessiteu una segona opinió o una arquitectura objectiu sòlida, el següent pas acostuma a ser una breu anàlisi amb exemples reproduïbles. Contacte:

En l’àmbit funcional també tenen un paper important Delphi Dataset Mapping i Legacy Delphi quan integracions, fluxos de dades i evolució han de funcionar de manera neta i coordinada.

Parlar sobre un projecte o una iniciativa de modernització amb Net-Base.

Comparteix la publicació

Comparteix aquesta publicació directament

LinkedIn, X, XING, Facebook, WhatsApp i E-Mail estan disponibles de forma immediata. Per a Instagram preparem directament l’enllaç i un text breu.

Correu electrònic

Instagram s'obre en una pestanya nova. L'enllaç i el text curt es copien prèviament al porta-retalls.