V rozrůstajících se Delphi-systémech je mapování datasetu na objekt zřídka ten čistý případ „jedno pole = jedna vlastnost“. V individuálním podnikatelském softwaru se místo toho setkáte s aliasovými sloupci z view, výsledky joinů s duplicitními názvy polí, „prázdnými“ hodnotami jako 0 nebo ' ', typovanými poli, která dnes vrací VARCHAR a zítra INTEGER, a sloupci, které podle vyhledávacího dialogu prostě nejsou přítomny. Právě tam mnoho mapperů selhává: buď jsou „magické“ (a tím obtížně laditelné), nebo jsou tak striktní, že i volitelné pole zastaví provoz.
Tento úryvek zdrojového kódu ukazuje pragmatický mapper pro Delphi, který záměrně není ORM, ale čistě adresuje nejdůležitější legacy okrajové případy: jednoznačné rozlišení polí, kontrolovanou konverzi, null-semantiku, volitelná pole a srozumitelné chybové hlášky. Hodí se pro Data-Access-Layer (DAL, tedy vrstvu, která zapouzdřuje přístup k datům) nebo repository-patterny – a lze jej dobře kombinovat s BDE-náhradou s nativním napojením (Delphis knihovna přístupu k datům pro mnoho DB).
Proč standardní mapování u starých struktur selhává
Několik typických příčin z provozu, které se při „čistém“ novém návrhu málokdy vyskytují:
- Víceznačné názvy polí: Join vrací
IDz více tabulek; v datasetu se pak jmenujeID,ID_1nebo je přejmenováno SQL aliasem. - Sémantické nuly:
0znamená „neznámé“,'1899-12-30'je „žádné datum“,' 'je „nevyplněno“. - Proměnlivé typy: View nekastuje; ovladač dodá
ftWideStringmístoftInteger. Konverze Variant se stává zdrojem chyb. - Volitelné sloupce: Vyhledávací dialog podle filtru používá jiné SELECT-seznamy. Kód ale očekává pole „vždy“.
- Laditelnost: Když se mapování ztratí v RTTI, je hledání chyb v datech zákazníka obtížné (které pole, která hodnota, který typ?).
Přístup: plán mapování místo konvence, s řízenou konverzí
Jádrem je plán mapování: seznam pravidel „vlastnost X pochází z pole A nebo B, je volitelná/povinná, používá konvertor Y“. Tím zůstává mapování deklarativní, ale není „neviditelné“ jako u mnoha ORM mechanismů. Navíc může mapper pro každé pole vyhodit výstižnou výjimku včetně názvu pole, datového typu a surové hodnoty.
Důležité: Záměrně mapujeme z TDataSet, ne z konkrétní třídy BDE-Ablosung mit nativer Anbindung. Tím zůstane kompatibilní s TFDQuery, TClientDataSet nebo i cizími komponentami.
Ukázka zdrojového kódu: laditelné mapování datasetu na objekt pro legacy sloupce
Kód implementuje:
- Řešení polí přes seznam priorit (aliasy/záložní varianty)
- Ošetření povinných/volitelných polí
- Null-semantiku přes konvertory (např.
0 => Null) - Stabilní chybová hlášení s kontextem
- Debug-hook, který umožní reprodukovat problémy s mapováním při testování nebo v případě podpory
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);
// Konvertér přijímá Variant a vrací Variant (např. Null, Integer, String, TDateTime jako 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: volá setter pro každou specifikaci. Bez RTTI: explicitní přiřazení je lépe laditelné.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Pomocné konvertory
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 místo FieldByName: možné volitelně, bez výjimky
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 není aktivní.');
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('Chyba mapování: požadované pole pro %s nenalezeno. Kandidáti: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // volitelné: prostě přeskočit
end;
Raw := F.Value; // Variant; bere v úvahu 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;
// Povinné: NULL po konvertoru je chyba (častěji, než byste čekali)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Chyba mapování: %s je povinné, ale hodnota je NULL po konverzi. Pole %s (%s), surová hodnota=%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('Chyba mapování pro %s z pole %s (%s), surová hodnota=%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);
// toleruje také '0' jako řetězec
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);
// Úmyslně přísné: žádné "Try", které by potlačilo kvalitu dat.
// Formát se může lišit podle legacy; případně parametrizovat zde přes TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Jak prakticky používat Mapper (bez RTTI, přesto elegantně)
Der Mapper ruft eine Assign(TargetMember, Value)-Callback-Funktion auf. Das hält die Zuweisung explizit (und damit gut debugbar) und vermeidet RTTI-Zugriffe im Hot-Path. In der Praxis bauen Sie pro Objekt/DTO (Data Transfer Object, also ein Transportobjekt für Daten) einen kleinen „Zuweiser“.
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;Účel: Mapování je na jednom místě centrálně popsáno (Specs), ale přiřazení zůstává explicitní. V legacy situacích je to většinou lepší kompromis než plně automatické RTTI-mapování, protože okamžitě vidíte, která Property závisí na kterém názvu pole.
Předpoklady: Tento přístup očekává aktivní Dataset a aktuální pozici záznamu. Pro dávkové importy iterujete zvenčí přes while not DS.Eof do a voláte MapCustomer pro každý řádek.
Úskalí: Dbejte na VarToStr u BLOBů nebo memo polí; tam byste měli použít vlastní konvertory. A: „Required“ zde znamená po konvertoru. Pokud C_TrimToNull nastaví Required-pole na Null, je to záměrně – kvalitu dat je třeba řešit u zdroje nebo v procesu.
Varianty: Místo stringových Targetů můžete také použít Enum, abyste vyloučili překlepy. Alternativně lze Assign-funkci pro každý Spec uložit jako TProc<Variant>, pak Target-string zcela odpadne (o něco více boilerplate, ale méně chybových tříd).
Zařazení do architektury: DAL/Repository, Logging a provoz
V vrstvené architektuře (typicky: UI – Business – Datenzugriff) patří toto mapování do vrstvy přístupu k datům nebo do repository. Důležité je, aby se Dataset „nepropagoval“ dál: objekty/DTO jsou stabilnější rozhraní, zvláště pokud později doplníte REST-APIs nebo části přenesete do C# Services.
Pro provoz a podporu se vyplatí debugovací hook OnDebug. S ním můžete v testech nebo při reprodukovatelných podpůrných případech protokolovat, která pole byla skutečně namapována. V produkčních systémech by to mělo být cílené a vypínatelné, jinak bude logování příliš nákladné nebo datově náročné.
Smysluplné použití debug-hooku
- Unit-testy: Ověřit, zda konkrétní SQL dotaz opravdu vrací všechna povinná (Required) pole.
- Diagnostika: Při problémech u zákazníka hned poznáte „Pole tu nebylo“ vs. „Hodnotu nebylo možné konvertovat“.
- Fáze migrace: Při přejmenovávání Views/sloupců můžete paralelně udržovat seznamy kandidátů, dokud není vše přesunuto.
Kdy tento přístup selhává (a co je pak lepší)
Ukázané mapování dataset→objekt je silné, když je zdroj dat neklidný a přesto potřebujete deterministické chování. Typicky ztrácí výhodu v dvou situacích:
- Velká množství (např. hromadný export): konverze Variant a vyhledávání podle názvu pole mohou začít být znatelné. V takovém případě se vyplatí předpočítávat cache indexů polí pro každé SQL (např.
FieldByNamejednorázově pro Dataset, ne pro řádek). - Velké množství DTO‑typů: Když napíšete stovky mapperů, stane se z toho boilerplate téma. Pak může být smysluplný RTTI‑založený přístup s atributy – ale pouze pokud přísně kontrolujete debug výstupy a konvertory.
Dobrým kompromisem je: řešení rozpoznávání polí a konverze jako zde (explicitní, tolerantní k chybám tam, kde je třeba), ale s generovaným kódem (např. přes interní šablony) místo ručně psaného.
Závěr: Stabilita díky explicitním pravidlům – s jasnými hranicemi použití
U legacy datasetů s aliasy, volitelnými sloupci a historickou null‑semantikou je mapování dataset→objekt úspěšné především tehdy, když zůstane explicitní a schopné diagnostiky. Mapping‑plán složený z kandidátních seznamů, povinné/volitelné (Required/Optional) a konvertorů dělá přesně to: můžete technický dluh stabilizovat postupně, aniž byste hned zaváděli ORM nebo databázi „najednou“ normalizovali.
Limity se projeví u extrémních požadavků na výkon a při velmi mnoha tipech – v tom případě potřebujete caching nebo automatizované generování kódu. Pro typický podnikový software s vyvinutými procesy je tento přístup však spolehlivým nástrojem, jak opět oddělit přístup k datům a doménové modely a zlepšit jejich udržovatelnost.
Pokud u konkrétního legacy‑mapování (FireDAC, Views, nepořádek JOINů, Null‑Semantik) potřebujete druhý názor nebo spolehlivou cílovou architekturu, je dalším krokem obvykle krátká analýza s reprodukovatelnými příklady. Kontakt:
V odborném kontextu hrají také Delphi Dataset Mapping a Legacy Delphi důležitou roli, pokud musí integrace, datové toky a další vývoj správně spolupracovat.