Net-Base Rivista

10.05.2026

Mapping dataset->oggetto per strutture legacy non standard: stabile, debuggabile, senza magia ORM

Quando i dataset legacy si sono sviluppati storicamente, i mapper standard spesso si inceppano su colonne alias, mix di tipi e strutture di join variabili. Questo frammento di codice sorgente mostra una mappatura da dataset a oggetto robusta e debuggabile in Delphi: con piano di mapping, convertitori, semantica dei null...

10.05.2026

Nei sistemi Delphi cresciuti nel tempo, il mapping da dataset a oggetto raramente corrisponde al caso pulito “un campo = una property”. Nel software aziendale personalizzato invece si incontrano colonne alias dalle view, risultati di join con nomi di campo duplicati, valori “vuoti” come 0 o ' ', campi tipizzati che oggi restituiscono VARCHAR e domani INTEGER, e colonne che a seconda della finestra di ricerca semplicemente non sono presenti. È proprio qui che molti mapper falliscono: o diventano troppo “magici” (e quindi difficili da debugare), oppure sono così rigidi che anche un campo opzionale interrompe l’esecuzione.

Questo frammento di codice mostra un mapper pragmatico per Delphi, che consapevolmente non è un ORM, ma affronta in modo pulito i principali casi marginali legacy: risoluzione univoca dei campi, conversione controllata, semantica delle null, campi opzionali e messaggi di errore comprensibili. È adatto per Data-Access-Layer (DAL, cioè uno strato che incapsula l’accesso ai dati) o pattern Repository – e si integra bene con la sostituzione BDE con collegamento nativo (la libreria di accesso ai dati di Delphi per molti DB).

Perché il mapping standard fallisce nelle strutture legacy

Alcune cause tipiche riscontrate in esercizio, che in un redesign “pulito” si vedono raramente:

  • Nomi di campo ambigui: una join restituisce ID da più tabelle; nel dataset compare quindi ID, ID_1 o viene rinominato tramite alias SQL.
  • Null semantiche: 0 significa “sconosciuto”, '1899-12-30' è “nessuna data”, ' ' è “non valorizzato”.
  • Tipi variabili: una View non effettua cast; il driver fornisce ftWideString invece di ftInteger. La conversione di Variant diventa fonte di errori.
  • Colonne opzionali: una finestra di ricerca utilizza, a seconda del filtro, liste SELECT diverse. Il codice però si aspetta che i campi siano “sempre” presenti.
  • Debuggabilità: se il mapping scompare nella RTTI, la ricerca degli errori sui dati del cliente diventa difficile (quale campo, quale valore, quale tipo?).

Approccio: piano di mapping invece di convenzione, con conversione controllata

Il nucleo è un Mapping-Plan: una lista di regole “la property X proviene dal campo A o B, è opzionale/required, usa il convertitore Y”. In questo modo il mapping resta dichiarativo, ma non “invisibile” come avviene in molti meccanismi ORM. Inoltre il mapper può sollevare per ogni campo un’eccezione significativa, comprensiva di nome campo, tipo dati e valore grezzo.

Importante: mappiamo intenzionalmente da TDataSet, non da una specifica classe BDE-Ablosung mit nativer Anbindung. Così rimane compatibile con TFDQuery, TClientDataSet o anche componenti di terze parti.

Snippet di codice: mapping dataset-a-oggetto debugabile per colonne legacy

Il codice implementa:

  • Risoluzione dei campi tramite una lista di priorità (alias/fallback)
  • Gestione di campi obbligatori/opzionali
  • Semantica delle null tramite convertitori (ad es. 0 => Null)
  • Messaggi di errore stabili con contesto
  • Un hook di debug per poter ricostruire i problemi di mapping in fase di test o nel supporto
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);

  // Il convertitore riceve un Variant e restituisce un Variant (es. Null, Integer, String, TDateTime come 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: invoca lo setter per ogni Spec. Nessun RTTI: l'assegnazione esplicita è più agevole da debug.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Convertitori di supporto
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 := '<variant non stampabile>';
  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 invece di FieldByName: possibile opzionale, senza eccezione
    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('Il dataset non è attivo.');

  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('Errore di mapping: campo obbligatorio per %s non trovato. Candidati: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // opzionale: saltare
    end;

    Raw := F.Value; // Variant; considera 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 dopo conversione è un errore (più frequente di quanto si pensi)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Errore di mapping: %s è obbligatorio, ma il valore è NULL dopo la conversione. Campo %s (%s), valore grezzo=%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('Errore di mapping per %s dal campo %s (%s), valore grezzo=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Convertitori }

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);
    // accetta anche '0' come stringa
    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);

    // Volutamente rigoroso: nessun "Try" che sopprima problemi di qualità dei dati.
    // Il formato può variare a seconda del legacy; eventualmente parametrizzare qui tramite TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Come utilizzare il Mapper nella pratica (senza RTTI, ma comunque elegante)

Il Mapper richiama una funzione di callback Assign(TargetMember, Value). Questo mantiene l’assegnazione esplicita (e quindi facilmente debugabile) e evita accessi RTTI nel percorso critico. In pratica costruite per ogni oggetto/DTO (Data Transfer Object, ovvero un oggetto di trasporto dei dati) un piccolo «assegnatore».

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;

Scopo: Il mapping è descritto in modo centrale in un punto (Specs), ma l’assegnazione rimane esplicita. In situazioni Legacy questa è spesso la decisione migliore rispetto a un mapping RTTI completamente automatico, perché si vede subito quale property dipende da quali nomi di campo.

Condizioni al contorno: L’approccio presuppone un DataSet attivo e una posizione record corrente. Per import batch iterate esternamente su while not DS.Eof do e chiamate MapCustomer per ogni riga.

Insidie: Prestate attenzione a VarToStr con BLOB o campi memo; lì dovreste usare convertitori dedicati. E: «Required» significa qui dopo il convertitore. Se C_TrimToNull imposta un campo Required a Null, è intenzionale — la qualità dei dati deve allora essere chiarita alla fonte o nel processo.

Varianti: Invece di target stringa potete usare un enum per escludere errori di battitura. In alternativa la funzione Assign può essere salvata per ogni Spec come TProc<Variant>, eliminando completamente la stringa Target (un po‘ più di boilerplate, ma con una riduzione ulteriore delle possibili classi di errore).

Collocazione nell’architettura: DAL/Repository, logging e gestione operativa

In un’architettura a layer (tipico: UI – business – accesso ai dati) questo mapping appartiene allo strato di accesso ai dati o a un repository. È importante che il DataSet non venga «passato oltre»: oggetti/DTO sono l’interfaccia più stabile, soprattutto se in seguito aggiungerete API REST o esternalizzerete parti in C# Services.

Per il funzionamento e il supporto conviene usare l’hook di debug OnDebug. Con esso potete, nei test o in casi di supporto riproducibili, registrare quali campi sono effettivamente stati mappati. Nei sistemi produttivi questo dovrebbe essere attivabile in modo mirato e disattivabile; altrimenti il logging diventa troppo costoso o troppo voluminoso in termini di dati.

Uso sensato dell’hook di debug

  • Test unitari: Verificare se una specifica istruzione SQL fornisce effettivamente tutti i campi obbligatori.
  • Diagnosi: In caso di problemi con clienti vedrete subito «campo non presente» vs. «valore non convertibile».
  • Fasi di migrazione: Quando si modificano view/nomi delle colonne potete mantenere in parallelo liste di candidati finché tutto non è migrato.

Quando questo approccio diventa inadeguato (e cosa è meglio in quel caso)

Il mapping da dataset a oggetto mostrato è robusto quando la sorgente dati è instabile e si richiede comunque un comportamento deterministico. Tipicamente diventa inadeguato in due situazioni:

  • Volumi molto grandi (es. esportazione massiva): la conversione Variant e la ricerca per nome campo possono diventare rilevanti in termini di prestazioni. In tal caso conviene un caching pre-calcolato dell’indice dei campi per SQL (es. FieldByName una tantum per dataset, non per riga).
  • Un numero molto elevato di tipi DTO: se scrivete centinaia di mapper il boilerplate diventa un problema. Allora un approccio basato su RTTI con attributi può essere sensato – ma solo se controllate rigorosamente le uscite di debug e i convertitori.

Una buona via di mezzo è: risoluzione dei campi e conversione come qui (esplicita, tollerante agli errori quando necessario), ma con codice generato (es. tramite template interni) invece che „scritto a mano“.

Conclusione: stabilità tramite regole esplicite – con limiti d’impiego chiari

Nei dataset legacy con alias, colonne opzionali e storica semantica dei NULL, il mapping da dataset a oggetto ha successo soprattutto se rimane esplicito e diagnosticabile. Il piano di mapping costituito da liste di candidati, campi obbligatori/opzionali e convertitori realizza proprio questo: potete stabilizzare progressivamente le eredità, senza introdurre immediatamente un ORM o normalizzare il database „tutto in una volta“.

I limiti sono nelle prestazioni estreme e in un numero molto elevato di tipi – in quei casi servono caching o generazione automatica di codice. Per tipiche applicazioni business con processi cresciuti nel tempo, l’approccio è tuttavia una leva affidabile per separare nuovamente accesso ai dati e modelli di dominio e renderli manutenibili.

Se per un mapping legacy concreto (FireDAC, Views, proliferazione incontrollata di join, semantica dei NULL) vi serve una seconda opinione o un’architettura target affidabile, il passo successivo è di solito una breve analisi con esempi riproducibili. Contatto:

Nel contesto tecnico anche Delphi Dataset Mapping e Legacy Delphi svolgono un ruolo importante, quando integrazioni, flussi di dati e evoluzione devono funzionare insieme in modo pulito.

Discutere un progetto o un’iniziativa di modernizzazione con Net-Base.

Condividi il post

Condividi direttamente questo articolo

LinkedIn, X, XING, Facebook, WhatsApp e e-mail sono immediatamente disponibili. Per Instagram prepariamo direttamente il link e un breve testo.

E-mail

Instagram si apre in una nuova scheda. Il link e il breve testo vengono copiati prima negli appunti.