Net-Base Žurnalas

10.05.2026

Dataset-į-objektą atvaizdavimas neįprastoms paveldėtoms struktūroms: stabilus, derinamas, be ORM-magijos

Jei Legacy duomenų rinkiniai istoriškai susikaupę, standartiniai mapperiai dažnai žlunga dėl alias stulpelių, tipų mišinių ir kintančių sujungimo struktūrų. Šis šaltinio fragmentas rodo atsparų, derinamą duomenų rinkinio į objektą susiejimą Delphi: su žemėlapio planu, konverteriais, null-semantika...

10.05.2026

Augančiuose Delphi-sistemuose duomenų rinkinio į objektą atvaizdavimas retai būna paprastas „vienas laukas = viena savybė“ atvejis. Vietoje to individualioje įmonių programinėje įrangoje susidursite su View aliased stulpeliais, join rezultatais su pasikartojančiais laukų pavadinimais, „tuščiais“ reikšmėmis kaip 0 arba ' ', tipizuotais laukais, kurie šiandien grąžina VARCHAR, o rytoj – INTEGER, ir stulpeliais, kurie priklausomai nuo paieškos dialogo tiesiog nėra. Būtent čia daugelis mapper’ių pakimba: arba jie tampa per daug „maginiai“ (ir todėl sunkiai derinami), arba jie yra tokie griežti, kad jau vienas neprivalomas laukas sustabdo veikimą.

Ši kodo ištrauka rodo pragmatišką atvaizdintoją skirta Delphi, kuris sąmoningai nėra ORM, bet aiškiai sprendžia svarbiausius paveldėtų sistemų kraštutinius atvejus: vienareikšmė laukų identifikacija, kontroliuojama konvertacija, NULL-semantika, neprivalomi laukai ir aiškiai atsekamos klaidų žinutės. Jis tinka Data-Access-Layer (DAL, tai sluoksnis, kuris kapsuliuoja duomenų prieigą) arba Repository patternams – ir lengvai derinamas su BDE-Ablosung mit nativer Anbindung (Delphi duomenų prieigos biblioteka daugeliui DB).

Kodėl standartinis atvaizdavimas žlunga senose struktūrose

Keletas tipinių eksploatacijos priežasčių, kurių retai matyti „švariame“ naujame dizaine:

  • Abiprasmiški laukų pavadinimai: JOIN grąžina ID iš kelių lentelių; duomenų rinkinyje jis tada vadinamas ID, ID_1 arba yra pervadintas per SQL alias.
  • Semantinės NULL reikšmės: 0 reiškia „nežinoma“, '1899-12-30' yra „ne data“, ' ' reiškia „neužpildyta“.
  • Kintantys tipai: View nekastina; tvarkyklė grąžina ftWideString vietoje ftInteger. Variant konvertacija tampa klaidų šaltiniu.
  • Neprivalomi stulpeliai: Paieškos dialogas pagal filtrą naudoja skirtingas SELECT eilutes. Tačiau kodas tikisi laukų „visada“.
  • Derinamumas: Jei atvaizdavimas dingsta į RTTI, klaidų paieška su kliento duomenimis tampa sudėtinga (koks laukas, kokia reikšmė, koks tipas?).

Požiūris: atvaizdavimo planas vietoje konvencijos, su kontroliuojama konvertacija

Branduolys yra mapping-planas: taisyklių sąrašas „Savybė X gaunama iš lauko A arba B, yra neprivaloma/privaloma, naudoja konverterį Y“. Taip atvaizdavimas lieka deklaratyvus, bet ne „nematomas“ kaip daugelyje ORM mechanizmų. Be to, mapperis gali kiekvienam laukui išmesti aiškią išimtį, įskaitant laukų pavadinimą, duomenų tipą ir žalią reikšmę.

Svarbu: mes sąmoningai atvaizduojame iš TDataSet, o ne iš konkrečios BDE-Ablosung mit nativer Anbindung klasės. Tai užtikrina suderinamumą su TFDQuery, TClientDataSet ar kitomis trečiųjų šalių komponentėmis.

Kodo ištrauka: derinamas duomenų rinkinio į objektą atvaizdavimas paveldėtiems stulpeliams

Kodas įgyvendina:

  • Laukų identifikavimą per prioritetų sąrašą (alias’ai / fallback’ai)
  • Privalomų / neprivalomų laukų valdymą
  • NULL-semantiką per konverterius (pvz. 0 => Null)
  • Stabilias klaidų žinutes su kontekstu
  • Derinimo hook’ą, leidžiantį atsekti atvaizdavimo problemas teste arba palaikymo atveju

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);

// Konverteris priima Variant ir grąžina Variant (pvz. Null, Integer, String, TDateTime kaip Double)
TFieldConverter = reference to function(const V: Variant): Variant;

TFieldSpec = record
TargetMember: string;
SourceCandidates: TArray;
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): TField;
function FieldTypeToString(FT: TFieldType): string;
function VariantToDiag(const V: Variant): string;
public
property OnDebug: TMapDebugEvent read FOnDebug write FOnDebug;

// MapOne: iškviečia setter’į kiekvienai Spec. Be RTTI: eksplicitus priskyrimas yra geriau derinamas.
procedure MapOne(DS: TDataSet; const Specs: TArray;
const Assign: TProc);
end;

// Pagalbiniai konverteriai
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): TField;
var
Name: string;
begin
Result := nil;
for Name in Candidates do
begin
// FindField vietoje FieldByName: galima be išimties
Result := DS.FindField(Name);
if Result <> nil then
Exit;
end;
end;

procedure TLegacyDatasetMapper.MapOne(DS: TDataSet; const Specs: TArray;
const Assign: TProc);
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 nėra aktyvus.‘);

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(‚Mapavimo klaida: privalomas laukas %s nerastas. Kandidatai: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // neprivaloma: tiesiog praleisti
end;

Raw := F.Value; // Variant; atsižvelgia į 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;

// Privaloma: Null po konverterio yra klaida (dažniau nei atrodo)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Mapavimo klaida: %s yra privalomas, tačiau reikšmė po konvertavimo yra NULL. Laukas %s (%s), pradinė reikšmė=%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(‚Mapavimo klaida priskiriant %s iš lauko %s (%s), pradinė reikšmė=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;

{ Konverteriai }

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);
// taip pat toleruoja ‚0‘ kaip eilutę
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);

// Tyčia griežta: nėra ‚Try‘, kuris nuslėptų duomenų kokybės problemas.
// Format gali priklausyti nuo legacy; esant reikalui parametrizuokite per TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;

end.

Kaip praktiškai naudoti Mapperį (be RTTI, bet vis tiek elegantiškai)

Mapper kviečia Assign(TargetMember, Value)-callback funkciją. Tai padaro priskyrimą eksplicitišką (ir taip gerai debuginamą) ir išvengia RTTI prieigų karštajame kelyje. Praktikoje kiekvienam objektui/DTO (Data Transfer Object, t. y. duomenų perdavimo objektui) sukuriate nedidelį „priskyrėją“.

Delphi
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;

Tikslas: žemėlapis (Specs) aprašomas centralizuotai vienoje vietoje, bet priskyrimai lieka eksplicitiški. Legacy situacijose tai dažniausiai yra geresnis kompromisas nei visiškai automatinis RTTI-mappingas, nes iš karto matote, kuri savybė priklauso nuo kurių lauko pavadinimų.

Sąlygos: šis metodas reikalauja aktyvaus Dataset ir einamosios įrašo pozicijos. Masiniams importams išorėje iteruokite per while not DS.Eof do ir kvieskite MapCustomer kiekvienai eilutei.

Kliūtys: atkreipkite dėmesį į VarToStr su BLOB ar Memo laukais; ten turėtumėte naudoti savo konverterius. Ir: „Required“ čia reiškia po konverterio. Jei C_TrimToNull nustato Required lauką į NULL, tai yra sąmoningas sprendimas – duomenų kokybę reikia spręsti šaltinyje arba procese.

Variantai: vietoj String-Targets galite naudoti Enum, kad išvengtumėte rašybos klaidų. Alternatyviai Assign-funkciją per kiekvieną Spec galima saugoti kaip TProc<Variant>, tuomet Target-String’as visiškai eliminuojamas (šiek tiek daugiau boilerplate, bet dar mažesnė klaidų tikimybė).

Vietos architektūroje: DAL/Repository, žurnavimas ir eksploatavimas

Sluoksniuotoje architektūroje (įprastai: UI – Business – duomenų prieiga) šis mappingas priklauso duomenų prieigos sluoksniui arba repository. Svarbu, kad Dataset nebūtų „perduodamas“: objektai/DTO yra stabilesnė sąsaja, ypač jei vėliau pridėsite REST-APIs arba perkeliate dalis į C# paslaugas.

Eksploatacijai ir palaikymui apsimoka naudoti Debug-Hook OnDebug. Juo galite testuose arba atkuriamuose palaikymo atvejuose protokoluoti, kurie laukai iš tikrųjų buvo susieti. Produktinėse sistemose tai turėtų būti valdomai įjungta ir išjungta; priešingu atveju žurnalo įrašymas tampa per brangus arba sukuria per daug duomenų.

Debug-Hook tikslingas naudojimas

  • Unit-Testai: Patikrinkite, ar konkretus SQL užklausa iš tikrųjų pateikia visus privalomus laukus.
  • Diagnostika: Klientų problemų atveju iš karto matote „laukas neegzistavo“ arba „nepavyko konvertuoti reikšmės“.
  • Migracijos etapai: Keisdami Views ar stulpelių pavadinimus galite lygiagrečiai palaikyti kandidatų sąrašus, kol viskas bus perkelta.

Kada šis požiūris nustoja veikti (ir kas tuomet yra geriau)

Parodytas dataset–į–objektą susiejimas yra patikimas, kai duomenų šaltinis yra nestabilus ir jums vis tiek reikia deterministinio elgesio. Jis paprastai praranda pranašumą dviem atvejais:

  • Labai didelės apimtys (pvz. masinis eksportas): Variantų konvertavimas ir laukų paieška pagal vardą gali tapti pastebima. Tada apsimoka iš anksto apskaičiuoti laukų indekso kešavimą kiekvienam SQL (pvz. FieldByName vieną kartą kiekvienam Dataset, ne kiekvienai eilutei).
  • Labai daug DTO tipų: Jei rašote šimtus mapperių, kartojamo šabloninio kodo kiekis tampa problema. Tada RTTI pagrįstas požiūris su atributais gali būti prasmingas – bet tik jei griežtai kontroliuojate derinimo išvestis ir konverterius.

Geras tarpinis sprendimas yra: laukų sprendimas ir konvertavimas taip, kaip čia (aiškiai, klaidoms tolerantiškai, kur reikia), bet su generuotu kodu (pvz. per vidinius šablonus) vietoj „rankomis rašyto“.

Išvada: stabilumas per aiškias taisykles – su aiškiomis taikymo ribomis

Senų datasetų su aliasais, pasirenkamais stulpeliais ir istorinėmis nulio semantikomis atveju dataset–į–objektą susiejimas ypač sėkmingas, jei jis lieka aiškus ir diagnozuojamas. Susiejimo planas, sudarytas iš kandidatų sąrašų, privalomų/pasirenkamų laukų ir konverterių, užtikrina būtent tai: galite palaipsniui stabilizuoti palikimą, neįvedę iš karto ORM ar nenormalizuodami duomenų bazės „vienu kartu“.

Ribos pasiekiamos ekstremalaus našumo scenarijuose ir esant labai daug tipų – tokiu atveju reikalingas kešavimas arba automatizuotas kodo generavimas. Tačiau įprastai verslo programinei įrangai su užaugusiais procesais šis požiūris yra patikimas svertas, leidžiantis duomenų prieigą ir domeno modelius vėl atskirti ir padaryti prižiūrimus.

Jei dėl konkretaus Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) reikia antros nuomonės arba patikimos tikslinės architektūros, dažniausiai kitas žingsnis yra trumpa analizė su atkuriamais pavyzdžiais. Kontaktas:

Fachiniame kontekste taip pat svarbų vaidmenį atlieka Delphi Dataset Mapping ir Legacy Delphi, kai integracijos, duomenų srautai ir tolesnė plėtra turi sklandžiai derėti.

Projektą arba modernizacijos užduotį aptarti su Net-Base.

Pasidalinti įrašu

Tiesiogiai pasidalinti šiuo įrašu

LinkedIn, X, XING, Facebook, WhatsApp ir el. paštas yra iš karto prieinami. Instagramui paruošiame nuorodą ir trumpą tekstą iš karto.

El. paštas

Instagram atidaromas naujame skirtuke. Nuoroda ir trumpas tekstas iš anksto nukopijuojami į iškarpinę.