Net-Base Revista

10.05.2026

Mapeamento de dataset para objeto para estruturas legadas incomuns: estável, depurável, sem magia de ORM

Quando conjuntos de dados legados cresceram ao longo do tempo, mapeadores padrão frequentemente falham diante de colunas com alias, misturas de tipos e estruturas de join variáveis. Este trecho de código-fonte mostra um mapeamento dataset-para-objeto robusto e depurável em Delphi: com plano de mapeamento, conversores, semântica de nulos...

10.05.2026

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 ID de várias tabelas; no Dataset aparece então como ID, ID_1 ou é renomeado por um alias SQL.
  • Nulos semânticos: 0 significa „desconhecido“, '1899-12-30' é „nenhuma data“, ' ' é „não preenchido“.
  • Tipos variáveis: uma View não faz cast; o driver fornece ftWideString em vez de ftInteger. 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
Delphi
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”.

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;

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. FieldByName uma ú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.

Partilhar publicação

Compartilhar esta publicação diretamente

LinkedIn, X, XING, Facebook, WhatsApp e e‑mail estão imediatamente disponíveis. Para o Instagram, preparamos o link e um texto curto de imediato.

E-mail

O Instagram abre numa nova aba. O link e o texto curto são copiados previamente para a área de transferência.