В развившихся Delphi-системах маппинг Dataset в объект редко представляет собой чистый случай «одно поле = одно свойство». В индивидуальном корпоративном ПО вы вместо этого столкнётесь с алиас-столбцами из представлений, результатами JOIN с дублирующимися именами полей, «пустыми» значениями в виде 0 или ' ', типизированными полями, которые сегодня возвращают VARCHAR, а завтра — INTEGER, и со столбцами, которые в зависимости от диалога поиска просто отсутствуют. Именно здесь многие мапперы дают сбой: либо они становятся слишком «магическими» (и поэтому трудны для отладки), либо настолько строгими, что уже одно опциональное поле останавливает работу.
Этот фрагмент исходного кода показывает прагматичный маппер для Delphi, который сознательно не является ORM, но чисто покрывает ключевые legacy-краевые случаи: однозначное разрешение полей, контролируемая конвертация, семантика нулей, опциональные поля и прозрачные сообщения об ошибках. Он подходит для Data-Access-Layer (DAL, то есть слоя, инкапсулирующего доступ к данным) или для паттерна Repository — и хорошо сочетается с BDE-заменой с нативным подключением (Delphis библиотека доступа к данным для множества СУБД).
Почему стандартное маппирование не работает на унаследованных структурах
Пара типичных причин из эксплуатации, которые редко встречаются при «чистом» новом проектировании:
- Двусмысленные имена полей: JOIN возвращает
IDиз нескольких таблиц; в наборе данных оно появляется какID,ID_1или переименовано через SQL‑алиас. - Семантические нули:
0означает «неизвестно»,'1899-12-30'— «нет даты»,' '— «не заполнено». - Колеблющиеся типы: Представление не приводит типы; драйвер возвращает
ftWideStringвместоftInteger. Конвертация Variant становится источником ошибок. - Опциональные столбцы: Диалог поиска использует в зависимости от фильтра разные списки SELECT. Код же ожидает поля «всегда».
- Отладимость: Когда маппинг уходит в RTTI и становится невидимым, поиск ошибок в данных клиента затрудняется (какое поле, какое значение, какой тип?).
Подход: план маппинга вместо конвенции, с контролируемой конвертацией
Суть — план маппинга: список правил «свойство X берётся из поля A или B, является опциональным/обязательным, использует конвертер Y». Так маппинг остаётся декларативным, но не «невидимым», как у многих ORM‑механизмов. Кроме того, маппер может для каждого поля выбрасывать информативное исключение, включая имя поля, тип данных и исходное значение.
Важно: мы намеренно маппируем из TDataSet, а не из конкретного класса BDE-Ablosung mit nativer Anbindung. Это сохраняет совместимость с TFDQuery, TClientDataSet и сторонними компонентами.
Исходный фрагмент: отлаживаемый маппинг Dataset в объект для унаследованных столбцов
Код реализует:
- Разрешение полей по списку приоритетов (алиасы/резервные варианты)
- Обработка обязательных/опциональных полей
- Семантика NULL через конвертеры (например
0 => Null) - Стабильные сообщения об ошибках с контекстом
- Отладочный хук для воспроизведения проблем маппинга в тесте или при обращении в поддержку
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: вызывает сеттер для каждой Spec. Без RTTI: явное присваивание проще отлаживать.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Hilfs-Konverter
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: возможно отсутствие поля без исключения
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; 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 nach Konverter ist ein Fehler (häufiger als man denkt)
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;
{ 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);
// допускает также ‚0‘ как строку
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 в hot-path. На практике для каждого объекта/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('Неизвестный TargetMember: ' + Target);
end);
Result := C;
except
C.Free;
raise;
end;
end;Цель: Отображение описано централизованно в одном месте (Specs), но присвоения остаются явными. В Legacy-ситуациях это обычно более сбалансированное решение по сравнению с полностью автоматическим RTTI-мэппированием, поскольку вы сразу видите, какое свойство зависит от каких имён полей.
Ограничения: Подход предполагает активный Dataset и текущую позицию записи. Для пакетного импорта внешним циклом пройдитесь по while not DS.Eof do и вызывайте MapCustomer для каждой строки.
Подводные камни: Обратите внимание на VarToStr при работе с BLOB или Memo-полями; там следует использовать собственные конвертеры. И: «Required» означает здесь после применения конвертера. Если C_TrimToNull установит Required-поле в Null, то это сделано намеренно — качество данных необходимо решать у источника или в процессе.
Варианты: Вместо строковых Target’ов можно использовать Enum, чтобы исключить опечатки. Альтернативно функцию Assign можно хранить в каждой спецификации как TProc<Variant>, тогда строка Target полностью отпадает (немного больше boilerplate, но ещё меньше классов ошибок).
Роль в архитектуре: DAL/Repository, логирование и эксплуатация
В слоистой архитектуре (обычно: UI – Business – доступ к данным) это отображение должно находиться в слое доступа к данным или в репозитории. Важно, чтобы Dataset не «проталкивался» дальше: объекты/DTO являются более стабильным интерфейсом, особенно если позже вы будете добавлять REST-APIs или выносить части в C# Services.
Для эксплуатации и поддержки полезен отладочный хук OnDebug. С его помощью в тестах или при воспроизводимых инцидентах поддержки можно протоколировать, какие поля действительно были сопоставлены. В продуктивных системах это должно быть включаемым и отключаемым выборочно, иначе логирование становится слишком дорогим или создаёт избыточные объёмы данных.
Отладочный хук — разумное применение
- Модульные тесты: Проверять, действительно ли конкретный SQL-запрос возвращает все обязательные (Required) поля.
- Диагностика: При проблемах у клиента вы сразу увидите «поля не было» vs. «не удалось конвертировать значение».
- Фазы миграции: При переключении views/имён столбцов можно поддерживать параллельные списки кандидатов, пока всё не будет перенесено.
Когда этот подход перестаёт работать (и что тогда лучше)
Показанное сопоставление Dataset с объектом хорошо работает, когда источник данных «шумный», а вам при этом нужно детерминированное поведение. Обычно оно перестаёт работать в двух ситуациях:
- Очень большие объёмы (например, массовый экспорт): конвертация Variant и поиск по имени поля могут стать заметными по нагрузке. Тогда имеет смысл подготовленное кэширование индекса полей для каждого SQL (например,
FieldByNameодин раз на Dataset, а не на каждую строку). - Очень много типов DTO: Если вы пишете сотни мапперов, шаблонный код становится проблемой. Тогда может быть полезен подход на основе RTTI с атрибутами — но только при строгом контроле отладочных выводов и конвертеров.
Хороший компромисс: разрешение полей и конвертация как здесь (явно, с толерантностью к ошибкам там, где нужно), но с генерируемым кодом (например, через внутренние шаблоны) вместо «ручного» написания.
Вывод: устойчивость через явные правила — с чёткими границами применения
При legacy-Datasets с алиасами, опциональными столбцами и исторической семантикой NULL сопоставление Dataset с объектом особенно успешно, когда оно остаётся явным и диагностируемым. План маппинга на основе списков кандидатов, обязательных/опциональных полей и конвертеров обеспечивает именно это: вы можете поэтапно стабилизировать унаследованные данные, не вводя сразу ORM и не нормализуя базу данных «в одно действие».
Ограничения проявляются при экстремальных требованиях к производительности и при очень большом количестве типов — тогда вам понадобятся кэширование или автоматическая генерация кода. Для типичной бизнес‑софтвары с эволюционировавшими процессами этот подход остаётся надёжным инструментом для разделения доступа к данным и доменных моделей и обеспечения их поддерживаемости.
Если вам нужна вторая точка зрения или надёжная целевая архитектура для конкретного legacy-mapping (FireDAC, views, разрастание join’ов, семантика NULL), следующим шагом обычно становится короткий анализ с воспроизводимыми примерами. Контакт:
В предметной области также важную роль играют Delphi Dataset Mapping и Legacy Delphi, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.