W dojrzałych Delphi-systemach mapowanie Dataset-zu-Objekt rzadko jest czystym przypadkiem „jedno pole = jedna właściwość”. W indywidualnym oprogramowaniu korporacyjnym natrafiają Państwo zamiast tego na kolumny-aliasy z widoków, wyniki JOIN z zduplikowanymi nazwami pól, „puste” wartości jako 0 lub ' ', pola typowane, które dziś zwracają VARCHAR, a jutro INTEGER, oraz kolumny, które zależnie od dialogu wyszukiwania po prostu nie występują. To właśnie tam wiele mapperów zawodzi: albo stają się „magiczne” (a więc trudne do debugowania), albo są tak restrykcyjne, że już jedno pole opcjonalne zatrzymuje działanie.
Ten fragment źródłowy pokazuje pragmatyczny mapper dla Delphi, który świadomie nie jest ORM, lecz w czysty sposób adresuje najważniejsze przypadki brzegowe w systemach legacy: jednoznaczne rozwiązywanie pól, kontrolowana konwersja, semantyka NULL, pola opcjonalne oraz przejrzyste komunikaty błędów. Nadaje się do warstwy Data-Access-Layer (DAL, czyli warstwa kapsułkująca dostęp do danych) lub wzorca Repository i dobrze komponuje się z BDE-Ablosung mit nativer Anbindung (Delphis Datenzugriffsbibliothek für viele DBs).
Dlaczego standardowe mapowanie zawodzi w istniejących strukturach
Kilka typowych przyczyn z eksploatacji, których rzadko spotyka się przy „czystym” nowym projekcie:
- Wieloznaczne nazwy pól: JOIN zwraca
IDz kilku tabel; w zestawie danych pojawia się wtedyID,ID_1lub nazwa jest zmieniona przez alias SQL. - Semantyczne NULL-e:
0oznacza „nieznane”,'1899-12-30'to „brak daty”,' 'to „nieuzupełnione”. - Zmienne typy: Widok nie wykonuje rzutowania; sterownik zwraca
ftWideStringzamiastftInteger. Konwersja wariantów staje się źródłem błędów. - Pola opcjonalne: Dialog wyszukiwania używa w zależności od filtra innych list SELECT. Kod jednak oczekuje pól „zawsze”.
- Możliwość debugowania: Jeśli mapowanie znika w RTTI, analiza błędów w danych klienta jest trudna (które pole, jaka wartość, jaki typ?).
Podejście: plan mapowania zamiast konwencji, z kontrolowaną konwersją
Rdzeniem jest plan mapowania: lista reguł „właściwość X pochodzi z pola A lub B, jest opcjonalna/wymagana, używa konwertera Y”. Dzięki temu mapowanie pozostaje deklaratywne, ale nie „niewidoczne” jak w wielu mechanizmach ORM. Dodatkowo mapper może dla każdego pola rzucać czytelną wyjątkową informację, zawierającą nazwę pola, typ danych i surową wartość.
Ważne: mapujemy celowo z TDataSet, a nie z konkretnej klasy BDE-Ablosung mit nativer Anbindung. Dzięki temu pozostaje kompatybilne z TFDQuery, TClientDataSet oraz komponentami zewnętrznymi.
Fragment źródłowy: debugowalne mapowanie Dataset-zu-Objekt dla kolumn legacy
Kod implementuje:
- Rozwiązywanie pól za pomocą listy priorytetów (Aliases/Fallbacks)
- Obsługę pól wymaganych/opcjonalnych
- Semantykę NULL przez konwertery (np.
0 => Null) - Stabilne komunikaty błędów z kontekstem
- Hook do debugowania, umożliwiający odtworzenie problemów z mapowaniem w testach lub przy wsparciu
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);
// Konwerter otrzymuje Variant i zwraca Variant (np. 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: wywołuje setter dla każdej specyfikacji. Brak RTTI: jawne przypisanie jest łatwiejsze do debugowania.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Konwertery pomocnicze
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 zamiast FieldByName: możliwe opcjonalnie, bez wyjątku
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 nie jest aktywny.');
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('Błąd mapowania: wymagane pole dla %s nie znalezione. Kandydaci: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcjonalnie: po prostu pominąć
end;
Raw := F.Value; // Variant; uwzględnia 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 po konwersji to błąd (częstsze niż się wydaje)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Błąd mapowania: %s jest wymagane, jednak wartość jest NULL po konwersji. Pole %s (%s), wartość surowa=%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('Błąd mapowania przy %s z pola %s (%s), wartość surowa=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konwertery }
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że '0' jako ciąg znaków
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);
// Celowo restrykcyjnie: brak "Try", który mógłby ukryć problemy z jakością danych.
// Format może się różnić w zależności od systemu legacy; w razie potrzeby parametryzować tu przez TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Jak praktycznie używać Mappera (bez RTTI, ale wciąż elegancko)
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;Zweck: Mapowanie jest opisane centralnie w jednym miejscu (Specs), ale przypisania pozostają jawne. W sytuacjach Legacy jest to zwykle lepszy kompromis niż w pełni automatyczne mapowanie RTTI, ponieważ od razu widać, która właściwość zależy od których nazw pól.
Randbedingungen: Podejście zakłada aktywny Dataset i aktualną pozycję rekordu. Für Batch-Importe iterieren Sie außen über while not DS.Eof do und rufen MapCustomer pro Row auf.
Stolperfallen: Zwróć uwagę na VarToStr przy BLOBs lub polach Memo; w tych miejscach powinieneś użyć własnych konwerterów. Und: „Required“ bedeutet hier nach Konverter. Wenn C_TrimToNull ein Required-Feld auf Null setzt, ist das Absicht – Datenqualität muss dann an der Quelle oder im Prozess geklärt werden.
Varianten: Zamiast String-Targets można też użyć Enum, żeby wyeliminować literówki. Alternatywnie lässt sich die Assign-Funktion pro Spec als TProc<Variant> speichern, dann entfällt der Target-String komplett (etwas mehr Boilerplate, dafür noch weniger Fehlerklasse).
Einordnung in Architektur: DAL/Repository, Logging und Betrieb
W architekturze warstwowej (typowo: UI – Business – dostęp do danych) to mapowanie należy do warstwy dostępu do danych lub do repository. Wichtig ist, dass das Dataset nicht „durchgereicht“ wird: Objekte/DTOs sind die stabilere Schnittstelle, gerade wenn Sie später REST-APIs nachrüsten oder Teile in C# Services auslagern.
Dla eksploatacji i wsparcia opłaca się używać debug-hooka OnDebug. Można dzięki niemu w testach lub przy powtarzalnych przypadkach wsparcia rejestrować, które pola rzeczywiście zostały zmapowane. W systemach produkcyjnych powinno to być stosowane selektywnie i możliwe do wyłączenia, w przeciwnym razie logowanie stanie się zbyt kosztowne lub zbyt obciążające danymi.
Rozsądne wykorzystanie Debug-Hook
- Testy jednostkowe: Sprawdzić, czy konkretne zapytanie SQL rzeczywiście dostarcza wszystkie pola wymagane.
- Diagnostyka: Przy problemach u klienta widzisz od razu „pole nie było obecne” vs. „wartości nie dało się skonwertować”.
- Fazy migracji: Przy przełączaniu widoków/nazw kolumn można prowadzić równoległe listy kandydatów, aż wszystko zostanie przeniesione.
Kiedy to podejście się nie sprawdza (i co wtedy jest lepsze)
Przedstawione mapowanie z Datasetu na obiekt jest silne, gdy źródło danych jest niestabilne, a mimo to potrzebujesz deterministycznego zachowania. Zwykle zawodzi w dwóch sytuacjach:
- Bardzo duże wolumeny (np. eksport masowy): konwersje Variant i wyszukiwanie po nazwie pola mogą stać się odczuwalne. Wtedy opłaca się wstępnie obliczany cache indeksu pól dla każdego SQL (np.
FieldByNamejednokrotnie na Dataset, nie na wiersz). - Duża liczba typów DTO: Jeśli piszesz setki mapperów, boilerplate staje się problemem. Wówczas sensowne może być podejście oparte na RTTI z atrybutami — ale tylko jeśli ściśle kontrolujesz wyjścia debugowe i konwertery.
Dobry kompromis to: rozwiązywanie pól i konwersja jak tutaj (jawnie, tolerancyjnie tam, gdzie trzeba), ale z generowanym kodem (np. przez wewnętrzne szablony) zamiast „ręcznie pisanego”.
Wnioski: stabilność dzięki jawnym regułom — z jasnymi granicami zastosowania
W przypadku legacy-Datasetów z aliasami, opcjonalnymi kolumnami i historyczną semantyką NULL mapowanie z Datasetu na obiekt jest szczególnie skuteczne wtedy, gdy pozostaje jawne i możliwe do diagnozy. Plan mapowania oparty na listach kandydatów, oznaczeniu wymagane/opcjonalne i konwerterach zapewnia dokładnie to: możesz stopniowo stabilizować zaległości historyczne, bez od razu wprowadzania ORM ani jednorazowej normalizacji bazy danych.
Granice pojawiają się przy ekstremalnych wymaganiach wydajnościowych oraz przy bardzo wielu typach — wtedy potrzebne jest cachowanie lub zautomatyzowane generowanie kodu. Dla typowego oprogramowania biznesowego z ugruntowanymi procesami podejście to jest jednak solidnym narzędziem, pozwalającym ponownie rozdzielić dostęp do danych i modele domenowe oraz uczynić je łatwiejszymi w utrzymaniu.
Jeśli przy konkretnym mapowaniu legacy (FireDAC, widoki, rozrost joinów, semantyka NULL) potrzebujesz drugiej opinii lub rzetelnej architektury docelowej, zwykle następnym krokiem jest krótka analiza z reprodukowalnymi przykładami. Kontakt:
W obszarze merytorycznym istotną rolę odgrywają też Delphi Dataset Mapping i Legacy Delphi, gdy integracje, przepływy danych i dalszy rozwój muszą współgrać w uporządkowany sposób.