Dans des systèmes Delphi hérités, le mappage Dataset-zu-Objekt est rarement le cas propre « un champ = une propriété ». Dans des logiciels d’entreprise sur mesure, on rencontre plutôt des colonnes alias issues de vues, des résultats de jointure avec des noms de champ dupliqués, des valeurs « vides » représentées par 0 ou ' ', des champs typés qui renvoient aujourd’hui VARCHAR et demain INTEGER, et des colonnes qui, selon le dialogue de recherche, peuvent tout simplement être absentes. C’est précisément là que beaucoup de mappers échouent : soit ils deviennent « magiques » (et donc difficiles à déboguer), soit ils sont si stricts qu’un champ optionnel suffit à interrompre le fonctionnement.
Ce fragment de code montre un mapper pragmatique pour Delphi, qui n’est délibérément pas un ORM, mais qui adresse proprement les principaux cas limites hérités : résolution univoque des champs, conversion contrôlée, sémantique des nulls, champs optionnels et messages d’erreur traçables. Il convient pour les Data-Access-Layer (DAL, c’est-à-dire une couche qui encapsule l’accès aux données) ou les patterns Repository – et se combine bien avec BDE-Ablosung mit nativer Anbindung (Delphis Datenzugriffsbibliothek für viele DBs).
Pourquoi le mapping standard échoue sur des structures héritées
Quelques causes typiques rencontrées en exploitation, rares lors d’une refonte « propre » :
- Noms de champs ambigus : une jointure renvoie
IDdepuis plusieurs tables ; dans le dataset il apparaît alors commeID,ID_1ou est renommé via un alias SQL. - Nulls sémantiques :
0signifie « inconnu »,'1899-12-30'est « pas une date »,' 'signifie « non renseigné ». - Variations de type : une vue ne force pas le cast ; le pilote renvoie
ftWideStringau lieu deftInteger. La conversion de Variant devient une source d’erreurs. - Colonnes optionnelles : un dialogue de recherche utilise, selon le filtre, différentes listes SELECT. Le code attend cependant des champs « toujours » présents.
- Débogabilité : si le mapping disparaît dans la RTTI, la recherche d’erreur sur des données clients est difficile (quel champ, quelle valeur, quel type ?).
Approche : Mapping-Plan statt Konvention, mit kontrollierter Konvertierung
Le noyau est un Mapping-Plan : une liste de règles « la propriété X provient du champ A ou B, est optionnelle/obligatoire, utilise le convertisseur Y ». Ainsi le mapping reste déclaratif, mais pas « invisible » comme chez de nombreux mécanismes ORM. De plus, le mapper peut lancer une exception explicite par champ, incluant le nom du champ, le type de donnée et la valeur brute.
Important : nous effectuons volontairement le mapping depuis TDataSet, et non depuis une classe concrète BDE-Ablosung mit nativer Anbindung. Cela le rend compatible avec TFDQuery, TClientDataSet ou d’autres composants tiers.
Source-Schnipsel: Debugbares Dataset-zu-Objekt Mapping für Legacy-Spalten
Le code implémente :
- Résolution des champs via une liste de priorités (alias/valeurs de repli)
- Gestion des champs obligatoires/optionnels
- Sémantique des nulls via convertisseurs (p. ex.
0 => Null) - Messages d’erreur stables avec contexte
- Un hook de débogage pour tracer les problèmes de mapping en test ou en support
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);
// Le convertisseur reçoit un Variant et retourne un Variant (p.ex. NULL, Integer, String, TDateTime en 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 : appelle le setter pour chaque Spec. Pas de RTTI : une affectation explicite est plus facile à déboguer.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Convertisseurs utilitaires
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 plutôt que FieldByName : possible en option, sans 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(‚Le dataset n\’est pas actif.‘);
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(‚Erreur de mapping : champ requis pour %s introuvable. Candidats : [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // optionnel : ignorer simplement
end;
Raw := F.Value; // Variant ; prend en compte 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;
// Requis : NULL après le convertisseur est une erreur (plus fréquent qu’on ne le croit)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Erreur de mapping : %s est requis, mais la valeur est NULL après conversion. Champ %s (%s), valeur brute=%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(‚Erreur de mapping pour %s depuis le champ %s (%s), valeur brute=%s : %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Convertisseurs }
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);
// tolère aussi ‚0‘ en tant que chaîne
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);
// Volontairement strict : aucun ‚Try‘ n\’étouffe la qualité des données.
// Le format peut varier selon le système legacy ; éventuellement paramétrer via TFormatSettings ici.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Comment utiliser le mapper en pratique (sans RTTI, mais de manière élégante)
Le mapper appelle une fonction de rappel Assign(TargetMember, Value). Cela rend l’affectation explicite (et donc aisément débogable) et évite les accès RTTI dans le hot-path. En pratique, vous créez pour chaque objet/DTO (Data Transfer Object, c’est-à‑dire un objet de transport de données) un petit « affecteur ».
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;But : Le mapping est décrit de manière centralisée en un seul endroit (Specs), mais l’affectation reste explicite. Dans des situations héritées, c’est généralement le meilleur compromis par rapport à un mapping RTTI entièrement automatique, car vous voyez immédiatement quelle propriété dépend de quel nom de champ.
Conditions cadres : L’approche suppose un DataSet actif et une position d’enregistrement courante. Pour des imports par lot, itérez en dehors avec while not DS.Eof do et appelez MapCustomer pour chaque ligne.
Pièges à surveiller : Faites attention à VarToStr pour les BLOBs ou les champs Memo ; vous devriez alors utiliser vos propres convertisseurs. Et : « Required » signifie ici après le convertisseur. Si C_TrimToNull met un champ Required à Null, c’est volontaire — la qualité des données devra être traitée à la source ou dans le processus.
Variantes : Au lieu de cibles sous forme de chaîne, vous pouvez aussi utiliser une énumération pour éviter les fautes de frappe. En alternative, la fonction Assign peut être stockée par Spec comme TProc<Variant>, ce qui supprime complètement la chaîne Target (un peu plus de boilerplate, mais moins de sources d’erreur).
Intégration dans l’architecture : DAL/Repository, Logging et exploitation
Dans une architecture en couches (typiquement : UI – Business – accès aux données), ce mapping appartient à la couche d’accès aux données ou à un repository. Il est important que le DataSet ne soit pas transmis tel quel : objets/DTOs constituent une interface plus stable, notamment si vous devez plus tard ajouter des APIs REST ou externaliser des parties vers des C# Services.
Pour l’exploitation et le support, le hook de débogage OnDebug est utile. Il permet, lors de tests ou de cas de support reproductibles, de consigner quels champs ont effectivement été mappés. Dans les systèmes en production, cela doit être activable et désactivable de manière ciblée, sinon le logging devient trop coûteux ou trop volumineux en données.
Utilisation pertinente du Debug-Hook
- Tests unitaires : vérifier qu’une instruction SQL donnée fournit bien tous les champs requis.
- Diagnostic : en cas de problème client, vous voyez immédiatement « le champ était absent » vs. « la valeur n’a pas pu être convertie ».
- Phases de migration : lors du renommage de views/noms de colonnes, vous pouvez maintenir en parallèle des listes de candidats jusqu’à ce que tout soit migré.
Quand cette approche devient insuffisante (et ce qui est alors préférable)
Le mapping Dataset-vers-objet présenté est robuste lorsque la source de données est instable et que vous avez néanmoins besoin d’un comportement déterministe. Il devient problématique typiquement dans deux situations :
- Volumes très importants (p. ex. export massif) : la conversion de Variant et la recherche par nom de champ peuvent devenir perceptibles. Il est alors pertinent de mettre en place un cache pré-calculé des index de champs par requête SQL (p. ex.
FieldByNameune seule fois par Dataset, pas par Row). - Un très grand nombre de types DTO : si vous écrivez des centaines de mappers, le code répétitif devient un problème. Une approche basée sur RTTI avec attributs peut être pertinente — mais seulement si vous contrôlez strictement les sorties de debug et les convertisseurs.
Un bon compromis : résolution des champs et conversion comme ici (explicite, tolérante aux erreurs là où c’est nécessaire), mais avec du code généré (p. ex. via des templates internes) plutôt que « écrit à la main ».
Conclusion : stabilité par des règles explicites — avec des limites d’utilisation claires
Pour des Legacy-Datasets avec aliases, colonnes optionnelles et une sémantique historique du NULL, le mapping Dataset-vers-objet réussit surtout s’il reste explicite et diagnostique. Le plan de mapping constitué de listes de candidats, champs requis/optionnels et convertisseurs réalise exactement cela : vous pouvez stabiliser les dettes techniques progressivement, sans introduire immédiatement un ORM ni normaliser la base de données « d’un coup ».
Les limites se situent en cas d’exigences de performance extrêmes et d’un très grand nombre de types — il faut alors du caching ou de la génération de code automatisée. Pour des logiciels métier typiques avec des processus établis, l’approche reste toutefois un levier fiable pour découpler et rendre maintenable l’accès aux données et les modèles de domaine.
Si vous avez besoin d’un second avis ou d’une architecture cible solide pour un mapping legacy concret (FireDAC, views, prolifération de joins, sémantique NULL), l’étape suivante est généralement une courte analyse avec des exemples reproductibles. Contact :
Dans le contexte fonctionnel, Delphi Dataset Mapping et Legacy Delphi jouent également un rôle important, lorsque intégrations, flux de données et évolution doivent s’articuler proprement.