See lähtekoodinäide näitab pragmaatilist mapperit Delphi jaoks, mis on teadlikult mitte ORM, kuid käsitleb puhtalt olulisemaid legacy-äärjuhtumeid: ühemõtteline väljaeristus, kontrollitud konverteerimine, null-semantika, valikulised väljad ja jälgitavad veateated. Sobib Data-Access-Layeri (DAL, ehk kiht, mis kapseldab andmepääsu) või repository-mustrite jaoks – ja kombineerub hästi BDE-asendamisega natiivse ühendusega (Delphi andmepääsubiblioteek paljudele andmebaasidele).
Miks tavaline mappimine vanades struktuurides läbi kukub
Mõned tüüpilised tööpõhised põhjused, mida „puhas“ uuesti kujundamise puhul harva näeb:
- Mitmetähenduslikud väljanimed: JOIN annab
IDmitmest tabelist; datasetis ilmub see siisID,ID_1või on see SQL-alias’ga ümber nimetatud. - Semantilised nullid:
0tähendab „tundmatu“,'1899-12-30'tähendab „pole kuupäeva“,' 'tähendab „pole täidetud“. - Muutuvad tüübid: View ei tee casti; draiver kannab üle
ftWideStringasemelftInteger. Variantide konverteerimine muutub vigade allikaks. - Valikulised veerud: Otsingudialoog lisab sõltuvalt filtrist erinevaid SELECT-vaid. Kood eeldab aga tihti, et väljad on „alati“ olemas.
- Debugitavus: Kui mappimine kaob RTTI-sse, on kliendiandmete põhjal veaotsing keeruline (milline väli, milline väärtus, milline tüüp?).
Lähenemine: mappimise plaan konventsiooni asemel, koos kontrollitud konverteerimisega
Tuum on mappimise plaan: reeglite loend „Property X tuleb väljalt A või B, on valikuline/nõutav, kasutab konverterit Y“. Sel viisil jääb mappimine deklaratiivseks, kuid mitte „nähamatuks“ nagu paljude ORM-mehhanismide puhul. Lisaks saab mapper iga välja puhul visata selge erandi, mis sisaldab välja nime, andmetüüpi ja toorväärtust.
Oluline: me mäppime teadlikult TDataSet-ist, mitte mingist konkreetsest BDE-Ablosung mit nativer Anbindung-klassist. Nii jääb lahendus ühilduvaks TFDQuery, TClientDataSet ja kolmandate osapoolte komponentidega.
Lähtekooditükk: debugitav dataseti–objekti mappimine legacy-veergude jaoks
Kood realiseerib:
- Väljade eristamise prioriteetide nimekirja alusel (aliased / fallbackid)
- Kohustuslike/valikuliste väljade käsitlemine
- Null-semantika konverterite kaudu (nt
0 => Null) - Stabiilsed veateated koos kontekstiga
- Debug-hook, mis võimaldab mappimise probleeme testis või tugijuhtumil jälitada
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);
// Konverter võtab Varianti ja tagastab Varianti (nt Null, Integer, String, TDateTime double'ina)
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: kutsub iga spec-i jaoks setter'i. Ei kasuta RTTI-d: otsene määramine on paremini debugitav.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Abikonverterid
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 FieldByName asemel: võimalus olla valikuline, ilma erandita
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 ei ole aktiivne.');
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('Mapping-viga: Required-väli %s ei leitud. Kandidandid: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valikuline: lihtsalt jätta vahele
end;
Raw := F.Value; // Variant; berücksichtigt 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 pärast konvertorit on viga (sagedamini kui arvata)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Mapping-viga: %s on Required, aga väärtus on NULL pärast konverteerimist. Välja %s (%s), algväärtus=%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('Mapping-viga: %s väljalt %s (%s), algväärtus=%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);
// talub ka '0' stringina
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);
// Tahtlikult range: 'Try' ei varja andmekvaliteedi probleeme.
// Formaat võib legacy puhul erineda; vajadusel parametriseerida siin TFormatSettings kaudu.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Kuidas Mapperit praktiliselt kasutada (ilma RTTI-ta, ent siiski elegantselt)
Mapper kutsub välja Assign(TargetMember, Value)-tagasikutsekutsefunktsiooni. See hoiab määramise eksplicitse (ja seega hästi debugitava) ning väldib RTTI-kutseid hot-path’is. Praktikas ehitate iga objekti/DTO (Data Transfer Object, ehk andmete edastusobjekt) jaoks väikese „määraja“.
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;Eesmärk: Mappimine on ühes kohas tsentraalselt kirjeldatud (Specs), kuid määramine jääb eksplicitseks. Legacy-situatsioonides on see sageli parem kompromiss kui täielikult automaatne RTTI-mappimine, sest näete kohe, milline property sõltub millisest väljanimest.
Eeldused: Selle lähenemise eelduseks on aktiivne DataSet ja praegune record-positsioon. Hulgimooduli importide puhul iterereerige väljastpoolt üle while not DS.Eof do ja kutsuge iga rea jaoks MapCustomer üles.
Püünised: Pöörake tähelepanu VarToStr-ile BLOB-ide või memo-väljade puhul; seal peaksite kasutama oma konvertereid. Ja: „Required“ tähendab siin pärast konverterit. Kui C_TrimToNull seab Required-välja nulliks, on see tahtlik — andmete kvaliteet tuleb siis lahendada kas allikas või protsessi tasandil.
Variandid: String-targetide asemel võite kasutada ka Enumit, et välistada trükivead. Alternatiivselt võib Assign-funktsiooni salvestada iga Spec-i jaoks kui TProc<Variant>, sel juhul kaob Target-string täielikult (veidi rohkem boilerplate’i, kuid veel vähem vigasid).
Asetus arhitektuuris: DAL/Repository, logimine ja käitamine
Layer-arkitektuuris (tüüpiline: UI – Business – andmepääs) kuulub see mappimine andmepääsukihile või repositooriumisse. Oluline on, et DataSeti ei „läkitata“ edasi: objektid/DTO-d on stabiilsem liides, eriti kui te hiljem REST-API-sid järeltöödeldate või osi välja viite C# Services.
Für Betrieb und Support lohnt sich der Debug-Hook OnDebug. Sie können damit in Tests oder bei reproduzierbaren Supportfällen protokollieren, welche Felder tatsächlich gemappt wurden. In produktiven Systemen sollte das gezielt und abschaltbar sein, sonst wird Logging zu teuer oder zu datenhaltig.
Debug-Hook sinnvoll nutzen
- Ühikutestid: Kontrollige, ob ein bestimmtes SQL-Statement wirklich alle kohustuslikud väljad liefert.
- Diagnoos: Kliendiprobleemide korral näete kohe „väli puudus“ vs. „väärtust ei õnnestunud konverteerida“.
- Migratsioonifaasid: Beim Umstellen von Views/Spaltennamen können Sie Kandidatenlisten parallel pflegen, bis alles umgezogen ist.
Wann dieser Ansatz kippt (und was dann besser ist)
Das gezeigte Dataset-zu-Objekt Mapping ist stark, wenn die Datenquelle unruhig ist und Sie trotzdem deterministisches Verhalten brauchen. Es kippt typischerweise in zwei Situationen:
- Väga suured mahud (z. B. Massenexport): Variant-Konvertierung und per Feldname suchen kann spürbar werden. Dann lohnt sich ein vorberechnetes Feldindex-Caching pro SQL (z. B.
FieldByNameeinmalig pro Dataset, nicht pro Row). - Väga palju DTO-tüüpe: Kui Sie hunderte Mapper schreiben, wird Boilerplate zum Thema. Dann kann ein RTTI-basierter Ansatz mit Attributen sinnvoll sein – aber nur, wenn Sie Debug-Ausgaben und Konverter strikt kontrollieren.
Ein guter Zwischenweg ist: Feldauflösung und Konvertierung wie hier (explizit, fehlertolerant wo nötig), aber mit generiertem Code (z. B. über interne Templates) statt „handgeschrieben“.
Fazit: Stabilität durch explizite Regeln – mit klaren Einsatzgrenzen
Bei Legacy-Datasets mit Aliases, optionalen Spalten und historischer Null-Semantik ist Dataset-zu-Objekt Mapping vor allem dann erfolgreich, wenn es explizit und diagnosefähig bleibt. Der Mapping-Plan aus Kandidatenlisten, Required/Optional und Konvertern schafft genau das: Sie können Altlasten schrittweise stabilisieren, ohne gleich ein ORM einzuführen oder die Datenbank „auf einmal“ zu normalisieren.
Die Grenzen liegen bei Extrem-Performance und bei sehr vielen Typen – dann brauchen Sie Caching oder automatisierte Code-Erzeugung. Für typische Business-Software mit gewachsenen Prozessen ist der Ansatz jedoch ein verlässlicher Hebel, um Datenzugriff und Domänenmodelle wieder entkoppelt und wartbar zu bekommen.
Wenn Sie bei einem konkreten Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) eine zweite Meinung oder eine belastbare Zielarchitektur brauchen, ist der nächste Schritt meist eine kurze Analyse mit reproduzierbaren Beispielen. Kontakt:
Im fachlichen Umfeld spielen auch Delphi Dataset Mapping und Legacy Delphi eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.
Arutada projekti või moderniseerimisettevõtmist koos Net-Base.