Bei gewachsenen Delphi-Systemen ist Dataset-zu-Objekt Mapping selten der saubere „ein Feld = eine Property“-Fall. In individueller Unternehmenssoftware treffen Sie stattdessen auf Alias-Spalten aus Views, Join-Ergebnisse mit doppelten Feldnamen, „leere“ Werte als 0 oder ' ', typisierte Felder, die heute VARCHAR und morgen INTEGER liefern, und Spalten, die je nach Suchdialog einfach nicht dabei sind. Genau dort kippen viele Mapper: Entweder werden sie zu „magisch“ (und damit schwer debugbar), oder sie sind so strikt, dass schon ein optionales Feld den Betrieb stoppt.
Този фрагмент от сорс показва прагматичен мапър за Delphi, който умишлено не е ORM, но адресира ясно най-важните гранични случаи при наследени системи: еднозначно разрешаване на полета, контролирана конверсия, семантика на null, опционални полета и проследими съобщения за грешки. Подходящ е за Data-Access-Layer (DAL — слой, който капсулира достъпа до данни) или Repository-патерни – и може да се комбинира добре с BDE-Ablosung mit nativer Anbindung (библиотека за достъп до данни на Delphi за много DBs).
Warum Standard-Mapping bei Altstrukturen scheitert
Няколко типични причини от експлоатацията, които рядко се срещат при „чист“ нов дизайн:
- Mehrdeutige Feldnamen: Join liefert
IDaus mehreren Tabellen; im Dataset heißt es dannID,ID_1oder ist per SQL-Alias umbenannt. - Semantische Nulls:
0bedeutet „unbekannt“,'1899-12-30'ist „kein Datum“,' 'ist „nicht gepflegt“. - Schwankende Typen: Ein View castet nicht; der Treiber liefert
ftWideStringstattftInteger. Variant-Konvertierung wird zur Fehlerquelle. - Optionale Spalten: Ein Suchdialog nutzt je nach Filter andere SELECT-Listen. Code erwartet aber Felder „immer“.
- Debuggability: Wenn Mapping in RTTI verschwindet, ist die Fehlersuche bei Kunden-Daten schwierig (welches Feld, welcher Wert, welcher Typ?).
Ansatz: Mapping-Plan statt Konvention, mit kontrollierter Konvertierung
Същността е един план за мапиране: списък с правила „Свойство X идва от поле A или B, е опционално/задължително, използва конвертор Y“. Така мапингът остава декларативен, но не „невидим“ както при много ORM-механизми. Освен това мапърът може за всяко поле да хвърли информативно изключение, включващо името на полето, типа данни и суровата стойност.
Важно: Ние умишлено мапваме от TDataSet, не от конкретна BDE-Ablosung mit nativer Anbindung-Klasse. Така остава съвместимо с TFDQuery, TClientDataSet или външни компоненти.
Source-Schnipsel: Debugbares Dataset-zu-Objekt Mapping für Legacy-Spalten
Кодът имплементира:
- Еднозначно разрешаване на полета чрез списък с приоритети (алиаси/резервни варианти)
- Обработка на задължителни/опционални полета
- Семантика на Null чрез конвертори (напр.
0 => Null) - Стабилни съобщения за грешки с контекст
- Debug-Hook, който позволява проследяване на проблеми с мапинга при тест или в поддръжката
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);
// Конвертор получава Variant и връща Variant (напр. Null, Integer, String, TDateTime като 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: извиква сетъри за всяка спецификация. Няма RTTI: експлицитното присвояване е по-добре за дебъг.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Помощни конвертори
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 := ‚<непечатим 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: възможно опционно, без Exception
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 не е активен.‘);
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(‚Грешка при мапване: не е намерено задължително поле за %s. Кандидати: [%s]‘,
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // по избор: просто пропуснете
end;
Raw := F.Value; // Variant; отчита 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;
// Задължително: NULL след конвертора е грешка (по-често, отколкото си мислите)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format(‚Грешка при мапване: %s е задължително, но стойността е NULL след конвертиране. Поле %s (%s), сурова стойност=%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(‚Грешка при мапване на %s от поле %s (%s), сурова стойност=%s: %s‘,
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Конвертори }
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);
// допуска и ‚0‘ като String
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);
// Умишлено стриктно: няма „Try“, който да заглуши качеството на данните.
// Форматът може да варира при различни Legacy системи; при нужда параметризирайте тук чрез TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Как да използваме Mapper-а практически (без RTTI, но все пак елегантно)
Mapper-ът извиква callback-функция Assign(TargetMember, Value). Това прави присвояването експлицитно (и следователно лесно за отстраняване на грешки) и избягва RTTI-достъпи в критичния път на изпълнение. На практика за всеки обект/DTO (Data Transfer Object, т.е. транспортен обект за данни) изграждате малък „приписвач“.
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;Цел: Mapping-ът е описан централизирано на едно място (Specs), но присвояването остава експлицитно. В наследени ситуации това обикновено е по-добро компромисно решение от напълно автоматично RTTI-мапиране, тъй като веднага виждате коя property зависи от кои имена на полета.
Предпоставки: Подходът предполага активно Dataset и текуща позиция на записа. За пакетни импорти итерарйте от външната страна с while not DS.Eof do и извиквайте MapCustomer за всеки ред.
Подводни камъни: Внимавайте при използване на VarToStr с BLOB-и или Memo полета; за тях трябва да използвате собствени конвертори. И още: „Required“ тук означава след конвертора. Ако C_TrimToNull превърне Required поле в Null, това е умишлено – качеството на данните трябва да се реши в източника или в процеса.
Варианти: Вместо стрингови Target-ове можете да използвате Enum, за да изключите печатни грешки. Алтернативно, Assign-функцията може да се съхранява за всяка Spec като TProc<Variant>, тогава Target-стрингът напълно отпада (малко повече boilerplate, но още по-малък клас от грешки).
Поставяне в архитектурата: DAL/Repository, логване и експлоатация
В многослоена архитектура (типично: UI – Business – достъп до данни) това мапиране принадлежи към слоя за достъп до данни или към Repository. Важно е Dataset-ът да не се „пропуска“ напред: обекти/DTO са по-стабилният интерфейс, особено ако по-късно ще надграждате REST-APIs или ще прехвърляте части в C# Services.
За експлоатация и поддръжка има смисъл да се използва дебъг-хукът OnDebug. С него можете в тестове или при възпроизводими случаи за поддръжка да протоколирате кои полета действително са били мапнати. В продуктивни системи това трябва да е целево и да може да се изключва, иначе логването става твърде скъпо или прекалено обемно по данни.
Разумно използване на Debug-Hook
- Unit тестове: Проверете дали конкретно SQL-изказване действително връща всички задължителни полета.
- Диагностика: При клиентски проблеми виждате веднага „полето не беше налично“ срещу „стойността не можа да бъде конвертирана“.
- Фази на миграция: При преместване на Views/имената на колоните можете паралелно да поддържате списъци с кандидати, докато всичко не бъде прехвърлено.
Кога този подход се проваля (и какво е по-добре тогава)
Показаното Dataset-до-обект мапване е надеждно, когато източникът на данни е „бурен“ и все пак ви трябва детерминистично поведение. То обикновено се проваля в две ситуации:
- Много големи обеми (напр. масов експорт): конвертирането на Variant и търсенето по име на поле могат да станат осезаеми. Тогава си струва предварително изчислено кеширане на индекса на полетата за всяко SQL (напр.
FieldByNameеднократно на Dataset, а не за всеки Row). - Много DTO типове: Ако пишете стотици mapper-и, повтарящият се шаблонен код става проблем. Тогава RTTI-базиран подход с атрибути може да е оправдан – но само ако строго контролирате debug-изходите и конверторите.
Добър междинен път е: разрешаването на полета и конвертирането както тук (експлицитно, толерантно към грешки където е нужно), но с генериран код (напр. чрез вътрешни шаблони) вместо „ръчно написан“.
Заключение: Стабилност чрез експлицитни правила – с ясни граници на приложимост
При Legacy-Datasets с aliases, опционални колони и историческа Null-Semantik Dataset-до-обект мапването е най-успешно, когато остане експлицитно и диагностируемо. Планът за мапване от списъци с кандидати, Required/Optional и конвертори постига точно това: можете постепенно да стабилизирате наследените структури, без веднага да въвеждате ORM или да нормализирате базата „изведнъж“.
Границите настъпват при екстремни изисквания за производителност и при много типове – тогава се нуждаете от кеширане или автоматизирано генериране на код. За типичен бизнес софтуер с утвърдени процеси обаче подходът е надежден механизъм за разделяне и поддръжка на достъпа до данни и домейн модела.
Ако за конкретно Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) ви трябва второ мнение или надеждна целева архитектура, следващата стъпка обикновено е кратък анализ с възпроизводими примери. Kontakt:
В предметната област Delphi Dataset Mapping и Legacy Delphi също играят важна роля, когато интеграции, потоци от данни и по-нататъшно развитие трябва да си взаимодействат чисто.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.