Em sistemas Delphi legados, o mapeamento Dataset-para-Objeto raramente é o caso limpo de „um campo = uma propriedade“. Em software empresarial personalizado, em vez disso encontra-se colunas alias de Views, resultados de Join com nomes de campo duplicados, valores „vazios“ como 0 ou ' ', campos tipados que hoje retornam VARCHAR e amanhã INTEGER, e colunas que, dependendo do diálogo de busca, simplesmente não estão presentes. É exatamente aí que muitos mapeadores falham: ou ficam „mágicos“ (e, portanto, difíceis de depurar), ou são tão rígidos que já um campo opcional paralisa a operação.
Este trecho de código-fonte mostra um mapeador pragmático para Delphi, que conscientemente não é um ORM, mas endereça de forma limpa os principais casos de borda em legados: resolução unívoca de campos, conversão controlada, semântica de nulo, campos opcionais e mensagens de erro rastreáveis. Ele se adequa a Data-Access-Layer (DAL, ou seja, uma camada que encapsula o acesso a dados) ou padrões de repositório – e combina bem com a BDE-Ablosung mit nativer Anbindung (biblioteca de acesso a dados de Delphi para vários DBs).
Por que o mapeamento padrão falha em estruturas legadas
Algumas causas típicas observadas em operação, que raramente aparecem em um redesenho „limpo“:
- Nomes de campo ambíguos: um Join retorna
IDde várias tabelas; no Dataset aparece então comoID,ID_1ou é renomeado por um alias SQL. - Nulos semânticos:
0significa „desconhecido“,'1899-12-30'é „nenhuma data“,' 'é „não preenchido“. - Tipos variáveis: uma View não faz cast; o driver fornece
ftWideStringem vez deftInteger. Conversões de Variant tornam-se fonte de erros. - Colunas opcionais: um diálogo de busca utiliza, dependendo do filtro, diferentes listas SELECT. O código, porém, espera os campos „sempre“.
- Depuração: se o mapeamento desaparece na RTTI, a busca por erros em dados de clientes fica difícil (qual campo, qual valor, que tipo?).
Abordagem: Mapping-Plan statt Konvention, com conversão controlada
O núcleo é um Mapping-Plan: uma lista de regras „a propriedade X vem do campo A ou B, é opcional/obrigatória, usa o conversor Y“. Assim o mapeamento permanece declarativo, mas não „invisível“ como em muitos mecanismos ORM. Além disso, o mapeador pode lançar por campo uma exceção informativa, incluindo nome do campo, tipo de dado e valor bruto.
Importante: mapeamos intencionalmente a partir de TDataSet, não de uma classe concreta BDE-Ablosung mit nativer Anbindung-classe. Isso mantém compatibilidade com TFDQuery, TClientDataSet ou mesmo com componentes de terceiros.
Trecho de código-fonte: mapeamento Dataset-para-Objeto depurável para colunas legadas
O código implementa:
- Resolução de campos por meio de uma lista de prioridades (aliases/fallbacks)
- Tratamento de campos obrigatórios/opcionais
- Semântica de nulo via conversores (z. B.
0 => Null) - Mensagens de erro estáveis com contexto
- Um gancho de depuração para permitir reproduzir problemas de mapeamento em teste ou em casos de suporte
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);
// O conversor recebe Variant e retorna Variant (p.ex. Null, Integer, String, TDateTime como 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: chama o setter para cada Spec. Sem RTTI: atribuição explícita é mais fácil de depurar.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;
// Conversores
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 em vez de FieldByName: possível opcionalmente, sem 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 não está ativo.');
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('Erro de mapeamento: campo obrigatório para %s não encontrado. Candidatos: [%s]',
[Spec.TargetMember, CandidatesJoined]));
end
else
Continue; // opcional: simplesmente pular
end;
Raw := F.Value; // Variant; considera 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 após conversão é um erro (mais comum do que se pensa)
if (Spec.Required = mrRequired) and VarIsNull(Val) then
begin
FT := FieldTypeToString(F.DataType);
raise EDataMappingError.Create(
Spec.TargetMember,
F.FieldName,
FT,
VariantToDiag(Raw),
Format('Erro de mapeamento: %s é Required, mas o valor é NULL após conversão. Campo %s (%s), valor bruto=%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('Erro de mapeamento em %s a partir do campo %s (%s), valor bruto=%s: %s',
[Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
end;
end;
end;
end;
{ Conversores }
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);
// tolera também '0' como 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);
// Intencionalmente estrito: nenhum "Try" que sufoque a qualidade dos dados.
// O formato pode variar conforme o legado; possivelmente parametrizar aqui via TFormatSettings.
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Como usar o Mapper na prática (sem RTTI, mas ainda elegante)
O Mapper invoca uma função de callback Assign(TargetMember, Value). Isso mantém a atribuição explícita (e assim facilmente depurável) e evita acessos RTTI no hot-path. Na prática, você constrói por objeto/DTO (Data Transfer Object, ou seja, um objeto de transporte de dados) um pequeno “atribuidor”.
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;Propósito: O mapeamento fica descrito de forma centralizada (Specs), mas a atribuição permanece explícita. Em situações Legacy essa costuma ser a melhor decisão de trade-off em comparação com um mapeamento RTTI totalmente automático, pois você vê imediatamente qual propriedade depende de quais nomes de campo.
Condições: A abordagem espera um Dataset ativo e uma posição de registro atual. Para importações em lote, itere externamente com while not DS.Eof do e chame MapCustomer por linha.
Armadilhas: Atenção ao VarToStr com BLOBs ou campos Memo; nesses casos você deve usar conversores específicos. E: “Required” significa aqui após o conversor. Se C_TrimToNull definir um campo Required como nulo, isso é intencional — qualidade dos dados precisa ser resolvida na origem ou no processo.
Variações: Em vez de targets string você pode também usar um enum para evitar erros de digitação. Alternativamente, a função Assign pode ser armazenada por Spec como TProc<Variant>, eliminando completamente a string Target (mais boilerplate, mas menos classes de erro).
Posicionamento na arquitetura: DAL/Repository, Logging e Operação
Em uma arquitetura em camadas (típico: UI – Business – acesso a dados) esse mapeamento pertence à camada de acesso a dados ou a um repository. É importante que o Dataset não seja “repassado”: objetos/DTOs são a interface mais estável, especialmente se você for implementar posteriormente REST-APIs ou externalizar partes em C# Services.
Para operação e suporte, vale a pena o Debug-Hook OnDebug. Com ele você pode, em testes ou em casos de suporte reproduzíveis, registrar quais campos foram efetivamente mapeados. Em sistemas produtivos isso deve ser usado de forma seletiva e desligável; caso contrário o logging torna‑se caro ou excessivamente volumoso em dados.
Uso adequado do Debug-Hook
- Testes unitários: Verificar se uma determinada instrução SQL realmente fornece todos os campos obrigatórios.
- Diagnóstico: Em problemas com clientes você identifica imediatamente “campo não estava presente” versus “valor não pôde ser convertido”.
- Fases de migração: Ao trocar Views/nomes de colunas, você pode manter listas de candidatos em paralelo até que tudo seja migrado.
Quando essa abordagem deixa de ser viável (e o que é melhor então)
O mapeamento de dataset para objeto demonstrado é robusto quando a fonte de dados é instável e você ainda precisa de um comportamento determinístico. Ele costuma deixar de ser viável em duas situações:
- Volumes muito grandes (p. ex. exportação em massa): conversão de Variant e busca por nome de campo podem tornar‑se perceptíveis. Nesse caso compensa um cache pré‑calculado de índices de campo por SQL (p. ex.
FieldByNameuma única vez por dataset, não por linha). - Muitos tipos de DTO: se você tiver que escrever centenas de mapeadores, o boilerplate vira um problema. Então uma abordagem baseada em RTTI com atributos pode fazer sentido — mas somente se você controlar estritamente as saídas de debug e os conversores.
Um bom caminho intermediário é: resolução de campos e conversão como aqui (explícitas, tolerantes a erros onde necessário), mas com código gerado (p. ex. via templates internos) em vez de “escrito à mão”.
Conclusão: estabilidade por regras explícitas — com limites de aplicação claros
Em datasets legados com aliases, colunas opcionais e semântica histórica de null, o mapeamento de dataset para objeto tem sucesso sobretudo quando se mantém explícito e diagnosticável. O plano de mapeamento composto por listas de candidatos, campos obrigatórios/opcionais e conversores fornece exatamente isso: você pode estabilizar passivos legados de forma gradual, sem introduzir um ORM de uma só vez ou normalizar o banco de dados “de uma vez”.
Os limites aparecem em cenários de performance extrema e com muitos tipos — então é necessário caching ou geração automatizada de código. Para software de negócios típico com processos maduros, a abordagem é, no entanto, uma alavanca confiável para desacoplar e tornar manuteníveis o acesso a dados e os modelos de domínio.
Se, em um mapeamento legado concreto (FireDAC, Views, crescimento desordenado de joins, semântica de null), você precisar de uma segunda opinião ou de uma arquitetura‑alvo robusta, o próximo passo normalmente é uma breve análise com exemplos reproduzíveis. Contato:
No âmbito funcional, Delphi Dataset Mapping e Legacy Delphi também desempenham um papel importante quando integrações, fluxos de dados e evolução precisam funcionar em conjunto de forma limpa.
Discutir projeto ou iniciativa de modernização com Net-Base.