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
IDda più tabelle; nel dataset compare quindiID,ID_1o viene rinominato tramite alias SQL. - Null semantiche:
0significa “sconosciuto”,'1899-12-30'è “nessuna data”,' 'è “non valorizzato”. - Tipi variabili: una View non effettua cast; il driver fornisce
ftWideStringinvece diftInteger. 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
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».
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.
FieldByNameuna 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.