Pie izaugušām Delphi-sistēmām Dataset‑uz‑objektu kartēšana reti ir tīrs „viens lauks = viena Property” gadījums. Individuālajā uzņēmumu programmatūrā vietā tam sastopama skatu alias‑kolonnu izmantošana, JOIN rezultāti ar dubultiem lauku nosaukumiem, „tukšas” vērtības kā 0 vai ' ', tipizēti lauki, kas šodien atgriež VARCHAR un rīt INTEGER, un kolonnas, kuras atkarībā no meklēšanas dialoga vienkārši nav klāt. Tieši šajos gadījumos daudzi mapperi kļūst neuzticami: vai nu tie kļūst „maģiski” (un tādējādi grūti atkļūdojami), vai arī ir tik stingri, ka jau viens izvēles lauks aptur darbību.
Šis avota fragments demonstrē pragmatisku mapperi priekš Delphi, kas apzināti nav ORM, bet sakārtoti risina svarīgākos mantojuma malgadījumus: viennozīmīga lauku atrisināšana, kontrolēta konvertācija, nulles semantika, izvēles lauki un izsekojamas kļūdu ziņas. Tas ir piemērots Data-Access-Layer (DAL, t.i., slānim, kas kapsulē datu piekļuvi) vai Repository patterniem — un labi kombinējams ar BDE-aizvietošanu ar natīvu pieslēgumu (Delphis datu piekļuves bibliotēka daudzām DBs).
Kāpēc standarta kartēšana izgāžas uz esošajām struktūrām
Daži tipiski ekspluatācijas iemesli, ko “tīrā” pārprojektēšanā reti redz:
- Divdomīgi lauku nosaukumi: JOIN atgriež
IDno vairākām tabulām; datasetā tas var parādīties kāID,ID_1vai būt pārdēvēts ar SQL‑aliasu. - Semaniskas nulles:
0nozīmē „nezināms”,'1899-12-30'ir „nav datuma”,' 'ir „neaizpildīts”. - Mainīgie datu tipi: View nekastē; draiveris piegādā
ftWideStringvietāftInteger. Variantu konvertācija kļūst par kļūdu avotu. - Izvēles kolonnas: Meklēšanas dialogs atkarībā no filtra izmanto dažādas SELECT‑sarakstes. Kods tomēr sagaida laukus „vienmēr”.
- Atkļūdojamība: Ja kartēšana pazūd RTTI, kļūdu meklēšana uz klientu datiem kļūst sarežģīta (kurš lauks, kāda vērtība, kāds tips?).
Pieeja: kartēšanas plāns nevis konvencija, ar kontrolētu konvertāciju
Kodols ir kartēšanas plāns: noteikumu saraksts „Property X nāk no lauka A vai B, ir optional/required, izmanto konvertoru Y”. Tas ļauj kartēšanai palikt deklaratīvai, bet ne „neredzamai” kā daudzos ORM mehānismos. Turklāt mapperis var izmest katram laukam izteiksmīgu izņēmumu, iekļaujot lauka nosaukumu, datu tipu un izejas (raw) vērtību.
Svarīgi: Mēs apzināti kartējam no TDataSet, nevis no konkrētas BDE-Ablosung mit nativer Anbindung‑klases. Tas nodrošina saderību ar TFDQuery, TClientDataSet vai arī ar trešo pušu komponentēm.
Source‑Schnipsel: atkļūdojams Dataset‑uz‑objektu kartēšanas risinājums mantojuma kolonām
Kods realizē:
- Lauku atrisināšanu, izmantojot prioritāšu sarakstu (Aliases/Fallbacks)
- Obligāto/izvēles lauku apstrādi
- Nulles semantiku caur konvertoriem (piem.,
0 => Null) - Stabilas kļūdu ziņas ar kontekstu
- Atkļūdošanas hook, lai kartēšanas problēmas testā vai atbalsta gadījumā varētu izsekot
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ētājs saņem Variant un atgriež Variant (piem., Null, Integer, String, TDateTime kā 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: izsauc setteri katrai Spec. Bez RTTI: eksplizīta piešķiršana ļauj labāku atkļūdojamību.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Palīgkonvertētāji
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 vietā FieldByName: iespējams kā opcija, bez izņēmuma
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 nav aktīvs.');
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('Mapēšanas kļūda: obligālais lauks %s nav atrasts. Kandidāti: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcija: vienkārši izlaist
end;
Raw := F.Value; // Variant; ņem vērā 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;
// Obligāts: NULL pēc konvertācijas ir kļūda (biežāk, nekā varētu domāt)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Mapēšanas kļūda: %s ir obligāts, bet vērtība ir NULL pēc konvertācijas. Lauks %s (%s), sākotnējā vērtība=%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('Mapēšanas kļūda pie %s no lauka %s (%s), sākotnējā vērtība=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Konvertētāji }
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);
// pieņem arī '0' kā stringu
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);
// Apzināti stingri: 'Try' netiek izmantots, lai netiktu slāpēta datu kvalitāte.
// Formāts var atšķirties atkarībā no legacy; ja nepieciešams, parametrizēt šeit ar TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Kā praktiski lietot Mapperu (bez RTTI, bet tomēr eleganti)
Mapper izsauc Assign(TargetMember, Value) callback-funkciju. Tas padara piešķiršanu skaidru (un līdz ar to labi atkļūdojamu) un izvairās no RTTI piekļuves hot-path. Praktiski katram objektam/DTO (Data Transfer Object, t.i., datu pārvadāšanas objekts) izveidojat nelielu „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;Mērķis: Mapēšana ir centrāli aprakstīta vienā vietā (Specs), bet piešķiršana paliek izteikta. Legacy situācijās tas parasti ir labāks kompromisa lēmums nekā pilnībā automātisks RTTI-mapping, jo jūs uzreiz redzat, kura īpašība ir atkarīga no kuriem lauka nosaukumiem.
Nosacījumi: Šī pieeja prasa aktīvu Dataset un esošu ieraksta pozīciju. Masveida importiem ārpusē iterējiet ar while not DS.Eof do un izsauciet MapCustomer par katru rindu.
Riski: Pievērsiet uzmanību VarToStr attiecībā uz BLOB vai Memo laukiem; tur jāizmanto savi konvertētāji. Un: „Required“ šeit nozīmē pēc konvertera. Ja C_TrimToNull padara Required lauku par Null, tas ir apzināti — datu kvalitāte tad jārisina avotā vai procesā.
Varianti: Vietā izmantojot virknes mērķus (String-Targets), var izmantot Enum, lai izslēgtu rakstīšanas kļūdas. Alternatīvi Assign-funkciju var saglabāt katram Spec kā TProc<Variant>, tad Target-virkne pilnībā izzūd (nedaudz vairāk boilerplate, bet mazāka kļūdu iespējamība).
Iekļaušana arhitektūrā: DAL/Repository, žurnālošana un ekspluatācija
Slāņu arhitektūrā (tipiski: UI – Business – datu piekļuve) šī mapēšana pieder datu piekļuves slānī vai repository. Svarīgi, ka Dataset netiek „nodots“ tālāk: objekti/DTO ir stabilāka saskarne, it īpaši, ja vēlāk ieviesīsiet REST-APIs vai izdalīsiet daļas uz C# pakalpojumiem.
Ekspluatācijā un atbalstam lietderīgs ir Debug-Hook OnDebug. Ar to testos vai reproducējamos atbalsta gadījumos var protokolēt, kuri lauki faktiski tika kartēti. Ražošanas sistēmās to jāizmanto mērķtiecīgi un ar iespēju izslēgt, citādi žurnālu veidošana kļūst pārāk dārga vai datuapjomīga.
Debug-Hook lietderīga izmantošana
- Unit testi: Pārbaudīt, vai konkrētais SQL-vaicājums patiešām piegādā visus Required laukus.
- Diagnostika: Klientu problēmu gadījumā jūs uzreiz redzat „lauks nebija pieejams” pret „vērtību neizdevās konvertēt”.
- Migrācijas posmi: Pārslēdzoties uz Views/kolonnu nosaukumiem, var paralēli uzturēt kandidātu sarakstus, līdz viss ir pārnests.
Kad šī pieeja neiztur (un kas tad ir labāks)
Rādītais Dataset-uz-objektu mapping ir spēcīgs, ja datu avots ir nemierīgs un jums tomēr nepieciešama deterministiska uzvedība. To parasti izjauc divas situācijas:
- Ļoti lieli apjomi (piem., masveida eksports): Variant-konvertēšana un meklēšana pēc lauka nosaukuma var kļūt jūtama. Tad ir vērts sagatavot lauku indeksa kešatmiņu katram SQL (piem.,
FieldByNamevienreiz uz Dataset, nevis uz katru rindu). - Ļoti daudzi DTO tipi: Ja rakstāt simtiem mapperu, boilerplate kļūst par problēmu. Tad var būt jēga RTTI-bāzētai pieejai ar atribūtiem — bet tikai, ja stingri kontrolējat debug-izvades un konvertorus.
Labs kompromiss ir: lauku atrisināšana un konvertēšana kā šeit (eksplicīta, kļūdizturīga tur, kur nepieciešams), bet ar ģenerētu kodu (piem., izmantojot iekšējos šablonus) nevis “rokām rakstītu”.
Secinājums: Stabilitāte, pateicoties eksplicitām noteiksmēm — ar skaidrām pielietošanas robežām
Ar legacy-datasetiem, kuros ir Aliases, izvēles kolonnas un vēsturiska nulles semantika, Dataset-uz-objektu mapping ir īpaši veiksmīgs, ja tas paliek eksplicīts un diagnostiski izmantojams. Mapping plāns, kas balstīts uz kandidātu sarakstiem, Required/Optional un konvertoriem, tieši to nodrošina: jūs varat pakāpeniski stabilizēt vēsturiskos slāņus, nepārejot uzreiz uz ORM vai nekavējoties normalizējot datubāzi.
Robežas parādās ekstremālai veiktspējai un ļoti daudziem tipiem — tad nepieciešams kešēšanas mehānisms vai automatizēta koda ģenerēšana. Taču tipiskai biznesa programmatūrai ar izveidojušiem procesiem šī pieeja ir uzticams instruments, lai atkal atdalītu un uzturētu datu piekļuvi un domēna modeļus.
Ja jums par konkrētu legacy-mapping (FireDAC, Views, Join-sajaukums, Null-Semantik) nepieciešama otrā atzinuma vai uzticama mērķarkitektūra, nākamais solis parasti ir īsa analīze ar reproducējamiem piemēriem. Kontakt:
Nozares kontekstā arī Delphi dataset mapping un Legacy Delphi spēlē nozīmīgu lomu, ja integrācijām, datu plūsmām un turpmākai attīstībai jādarbojas tīri kopā.