I etablerade Delphi-system är dataset-till-objekt-mappning sällan det rena „ett fält = en property“-fallet. I skräddarsydd företagsmjukvara stöter du istället på aliaskolumner från vyer, join-resultat med dubbla fältnamn, „tomma“ värden som 0 eller ' ', typade fält som idag levererar VARCHAR och i morgon INTEGER, och kolumner som helt enkelt saknas beroende på sökdialog. Det är exakt där många mapper fallerar: antingen blir de för „magiska“ (och därmed svåra att debugga), eller så är de så strikta att redan ett valfritt fält stoppar driften.
Denna source-schnipsel visar en pragmatisk mapper för Delphi, som medvetet inte är ett ORM, men som rent adresserar de viktigaste legacy-kantfallen: entydig fältupplösning, kontrollerad konvertering, null-semantik, valfria fält och spårbara felmeddelanden. Den lämpar sig för Data-Access-Layer (DAL, alltså ett lager som kapslar in dataåtkomst) eller repositorymönster – och kan kombineras väl med BDE-ersättning med nativen anslutning (Delphis dataåtkomstbibliotek för många DBs).
Varför standardmappning misslyckas med äldre strukturer
Några typiska orsaker från drift som man sällan ser vid ett „rent“ nykonstruktion:
- Tvetydiga fältnamn: En join returnerar
IDfrån flera tabeller; i datasetet heter det dåID,ID_1eller är omdöpt via SQL-alias. - Semantiska nulls:
0betyder „okänt“,'1899-12-30'är „inget datum“,' 'är „ej ifyllt“. - Varierande typer: En view castar inte; drivrutinen levererar
ftWideStringistället förftInteger. Variant-konvertering blir en felkälla. - Valfria kolumner: En sökdialog använder beroende på filter olika SELECT-listor. Koden förväntar sig dock fälten „alltid“.
- Debuggbarhet: Om mappningen försvinner in i RTTI blir felsökning mot kunddata svår (vilket fält, vilket värde, vilken typ?).
Ansats: Mappningsplan istället för konvention, med kontrollerad konvertering
Kärnan är en mappningsplan: en lista med regler „Property X kommer från fält A eller B, är valfri/obligatorisk, använder konverter Y“. På så sätt förblir mappningen deklarativ men inte „osynlig“ som hos många ORM-mekanismer. Dessutom kan mappen per fält kasta ett informativt undantag, inklusive fältnamn, datatyp och råvärde.
Viktigt: Vi mappar medvetet från TDataSet, inte från en konkret BDE-Ablosung mit nativer Anbindung-klass. Det håller kompatibiliteten med TFDQuery, TClientDataSet eller även tredjepartskomponenter.
Source-Schnipsel: Debuggbar Dataset-zu-Objekt Mapping för Legacy-Spalten
Koden implementerar:
- Fältupplösning via en prioriteringslista (Aliases/Fallbacks)
- Hantering av obligatoriska/valfria fält
- Null-semantik över konverterare (z. B.
0 => Null) - Stabila felmeddelanden med kontext
- En debug-hook för att kunna spåra mappningsproblem i test eller vid supportfall
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 tar Variant och returnerar Variant (t.ex. Null, Integer, String, TDateTime som 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: anropar setter för varje spec. Ingen RTTI: explicit tilldelning är lättare att debugga.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Hjälp-konverterare
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
// Använder FindField istället för FieldByName: tillåter att fält saknas utan undantag
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('Datasetet är inte aktivt.');
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('Mappingfel: Required-fält för %s hittades inte. Kandidater: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // valfritt: hoppa över
end;
Raw := F.Value; // Variant; tar hänsyn till 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 efter konvertering är ett fel (vanligare än man tror)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Mappingfel: %s är Required, men värdet är NULL efter konvertering. Fält %s (%s), råvärde=%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('Mappingfel vid %s från fält %s (%s), råvärde=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konverterare }
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);
// tolererar även '0' som sträng
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);
// Medvetet strikt: ingen "Try" som tystar datakvalitet.
// Formatet kan variera beroende på legacy; eventuellt parametriseras här via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Hur man använder Mapper i praktiken (utan RTTI, men ändå elegant)
Mappern anropar en Assign(TargetMember, Value)-callback-funktion. Det håller tilldelningen explicit (och därmed väl debuggbart) och undviker RTTI-åtkomster i hot-path. I praktiken bygger du per objekt/DTO (Data Transfer Object, alltså ett transportobjekt för data) en liten „tilldelare“.
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;Syfte: Mappningen beskrivs centralt på ett ställe (Specs), men tilldelningen förblir explicit. I legacy-situationer är detta oftast det bättre trade-off-beslutet än ett fullautomatiskt RTTI-mapping, eftersom du omedelbart ser vilken Property som beror på vilka fältnamn.
Randvillkor: Metoden förutsätter ett aktivt dataset och en aktuell record-position. För batchimporter itererar du utanför med while not DS.Eof do och kallar MapCustomer för varje rad.
Fallgropar: Var försiktig med VarToStr vid BLOBs eller memo-fält; där bör du använda egna konverterare. Och: „Required“ betyder här efter konverteraren. Om C_TrimToNull sätter ett Required-fält till null är det avsiktligt – datakvalitet måste då hanteras vid källan eller i processen.
Varianter: Istället för string-targets kan du också använda en Enum för att utesluta skrivfel. Alternativt kan Assign-funktionen per Spec sparas som TProc<Variant>, då försvinner Target-strängen helt (lite mer boilerplate, men färre felkällor).
Inplacering i arkitekturen: DAL/Repository, Logging och drift
I en lagerarkitektur (typiskt: UI – Business – dataåtkomst) hör denna mappning hemma i dataåtkomstlagret eller i ett Repository. Viktigt är att datasetet inte „passeras vidare“: Objekt/DTOs är det stabilare gränssnittet, särskilt om du senare adderar REST-API:er eller lägger ut delar i C# Services.
För drift och support är debug-hooken OnDebug värdefull. Med den kan ni i tester eller vid reproducerbara supportfall logga vilka fält som faktiskt mappats. I produktiva system bör det vara riktat och avstängbart, annars blir loggningen för dyr eller för datatät.
Använd Debug-Hooken effektivt
- Unit-Tester: Kontrollera om en given SQL-sats verkligen returnerar alla Required-fält.
- Diagnostik: Vid kundproblem ser ni omedelbart „fältet saknades“ kontra „värdet kunde inte konverteras“.
- Migrationsfaser: Vid övergång av views/kolumnnamn kan ni underhålla kandidatlistor parallellt tills allt är migrerat.
När detta angreppssätt brister (och vad som då är bättre)
Det visade dataset-till-objekt-mappningen är kraftfull när datakällan är ostadig och ni ändå behöver deterministiskt beteende. Den sviker typiskt i två situationer:
- Mycket stora mängder (t.ex. massexport): Variant-konvertering och sökningar per fältnamn kan bli märkbara. Då lönar sig ett förberäknat fältindex-caching per SQL (t.ex.
FieldByNameen gång per dataset, inte per rad). - Mycket många DTO-typer: Om ni skriver hundratals mapper blir boilerplate ett problem. Då kan ett RTTI-baserat tillvägagångssätt med attribut vara lämpligt – men endast om ni strikt kontrollerar debug-utskrifter och konverterare.
En bra mellanväg är: fältupplösning och konvertering som här (explicit, felförlåtande där det behövs), men med genererad kod (t.ex. via interna mallar) istället för „handskriven“.
Slutsats: Stabilitet genom explicita regler – med tydliga användningsgränser
Vid legacy-datasets med alias, valfria kolumner och historisk null-semantik är dataset-till-objekt-mappning framför allt framgångsrik när den förblir explicit och diagnostisk. Mapping-planen bestående av kandidatlistor, Required/Optional och konverterare uppnår precis det: Ni kan stegvis stabilisera befintliga restproblem och historiska särfall, utan att omedelbart införa ett ORM eller normalisera databasen „på en gång“.
Gränserna ligger vid extrem prestanda och mycket många typer – då behöver ni caching eller automatiserad kodgenerering. För typisk affärsprogramvara med växande processer är tillvägagångssättet däremot ett pålitligt verktyg för att åter koppla isär dataåtkomst och domänmodeller och göra dem underhållbara.
Om ni i ett konkret legacy-mappning (FireDAC, views, join-vildvuxnad, null-semantik) behöver en andra åsikt eller en trovärdig målarkitektur, är nästa steg vanligtvis en kort analys med reproducerbara exempel. Kontakt:
I det domänmässiga sammanhanget spelar också Delphi dataset-mappning och legacy Delphi en viktig roll, när integrationer, dataflöden och vidareutveckling måste samspela på ett rent sätt.
Diskutera projekt eller moderniseringsinitiativ med Net-Base.