Net-Base Revista

10.05.2026

Mapeo de Dataset a objeto para estructuras heredadas atípicas: estable, depurable, sin magia de ORM

Cuando los conjuntos de datos heredados se han desarrollado históricamente, los mapeadores estándar suelen fracasar ante columnas con alias, mezclas de tipos de datos y estructuras de JOIN cambiantes. Este fragmento de código fuente muestra un mapeo de dataset a objeto robusto y depurable en Delphi: con plan de mapeo, convertidores y semántica de NULL.

10.05.2026

En sistemas Delphi existentes, el Dataset-zu-Objekt Mapping rara vez es el caso limpio de „un campo = una propiedad“. En software empresarial personalizado, en su lugar encontrará columnas alias procedentes de views, resultados de joins con nombres de campo duplicados, valores „vacíos“ como 0 o ' ', campos tipados que hoy devuelven VARCHAR y mañana INTEGER, y columnas que según el diálogo de búsqueda sencillamente no están presentes. Ahí es donde fallan muchos mapeadores: o se vuelven demasiado „mágicos“ (y por tanto difíciles de depurar), o son tan estrictos que un campo opcional ya detiene la operación.

Este fragmento de código muestra un mapeador pragmático para Delphi, que conscientemente no es un ORM, pero aborda de forma limpia los principales casos marginales legacy: resolución única de campos, conversión controlada, semántica de nulos, campos opcionales y mensajes de error trazables. Es apto para Data-Access-Layer (DAL, es decir, una capa que encapsula el acceso a datos) o patrones Repository – y se puede combinar bien con BDE-sustitución con conexión nativa (Biblioteca de acceso a datos de Delphi para muchas DBs).

Por qué el mapeo estándar falla con estructuras antiguas

Algunas causas típicas en producción que rara vez aparecen en un rediseño „limpio“:

  • Nombres de campo ambiguos: un Join devuelve ID de varias tablas; en el Dataset aparece como ID, ID_1 o se renombra mediante alias SQL.
  • Nulos semánticos: 0 significa „desconocido“, '1899-12-30' es „no fecha“, ' ' es „no registrado“.
  • Tipos variables: una View no hace cast; el driver devuelve ftWideString en lugar de ftInteger. La conversión de Variant se convierte en fuente de errores.
  • Columnas opcionales: un diálogo de búsqueda usa, según el filtro, diferentes listas SELECT. Pero el código espera que los campos estén „siempre“.
  • Depurabilidad: cuando el mapping desaparece en RTTI, la búsqueda de errores en los datos del cliente es difícil (¿qué campo, qué valor, qué tipo?).

Enfoque: plan de mapeo en lugar de convención, con conversión controlada

El núcleo es un Mapping-Plan: una lista de reglas „la Property X proviene del campo A o B, es optional/required, usa el conversor Y“. Así el mapeo permanece declarativo, pero no „invisible“ como ocurre con muchos mecanismos ORM. Además, el mapeador puede lanzar por campo una excepción informativa, incluyendo nombre de campo, tipo de dato y valor bruto.

Importante: intencionalmente mapeamos desde TDataSet, no desde una clase concreta BDE-Ablosung mit nativer Anbindung. Así se mantiene compatible con TFDQuery, TClientDataSet o incluso componentes de terceros.

Fragmento de código: mapeo depurable de Dataset a objeto para columnas legacy

El código implementa:

  • Resolución de campos mediante una lista de prioridades (Aliases/Fallbacks)
  • Gestión de campos requeridos/opcionales
  • Semántica de nulos mediante conversores (p. ej. 0 => Null)
  • Mensajes de error estables con contexto
  • Un hook de depuración para poder reproducir problemas de mapeo en pruebas o en casos de soporte
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);

  // El convertidor recibe Variant y devuelve Variant (p. ej. 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: llama al setter para cada Spec. Sin RTTI: la asignación explícita es más fácil de depurar.
    procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
      const Assign: TProc<string, Variant>);
  end;

// Convertidores auxiliares
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 no imprimible>';
  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 en lugar de FieldByName: posible de forma opcional, sin lanzar excepción
    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('El conjunto de datos no está activo.');

  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('Error de mapeo: no se encontró el campo requerido para %s. Candidatos: [%s]',
            [Spec.TargetMember, CandidatesJoined]));
      end
      else
        Continue; // opcional: omitir
    end;

    Raw := F.Value; // Variant; tiene en cuenta 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 tras el convertidor es un error (más frecuente de lo que se piensa)
      if (Spec.Required = mrRequired) and VarIsNull(Val) then
      begin
        FT := FieldTypeToString(F.DataType);
        raise EDataMappingError.Create(
          Spec.TargetMember,
          F.FieldName,
          FT,
          VariantToDiag(Raw),
          Format('Error de mapeo: %s es requerido, pero el valor es NULL después de la conversión. Campo %s (%s), valor crudo=%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('Error de mapeo en %s desde el campo %s (%s), valor crudo=%s: %s',
            [Spec.TargetMember, F.FieldName, FT, VariantToDiag(Raw), E.Message]));
      end;
    end;
  end;
end;

{ Convertidores }

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 también '0' como cadena
    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 estricto: no se oculta la calidad de los datos con un "Try".
    // El formato puede variar según el sistema legado; si procede, parametrizar aquí mediante TFormatSettings.
    D := ISO8601ToDate(S, False);
    Result := D;
  end;
end;

end.

Cómo usar el Mapper en la práctica (sin RTTI, pero aún elegante)

El Mapper invoca una función de callback Assign(TargetMember, Value). Esto mantiene la asignación explícita (y por tanto fácil de depurar) y evita accesos RTTI en el camino crítico. En la práctica construye por cada objeto/DTO (Data Transfer Object, es decir, un objeto de transporte de datos) un pequeño «asignador».

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: El mapeo está descrito de forma centralizada en un único punto (Specs), pero la asignación permanece explícita. En situaciones Legacy suele ser la decisión de compromiso preferible frente a un mapeo totalmente automático por RTTI, porque permite ver de inmediato qué propiedad depende de qué nombres de campo.

Condiciones: El enfoque asume un Dataset activo y una posición de registro actual. Para importaciones por lotes itere externamente con while not DS.Eof do y llame a MapCustomer por cada fila.

Precauciones: Preste atención a VarToStr con BLOBs o campos Memo; en esos casos debe usar convertidores propios. Y: «Required» se evalúa aquí después del convertidor. Si C_TrimToNull convierte un campo Required a Null, es intencional: la calidad de los datos debe resolverse en la fuente o en el proceso.

Variantes: En lugar de usar Targets como cadenas también puede utilizar un enum para evitar errores tipográficos. Como alternativa, la función Assign puede almacenarse por Spec como TProc<Variant>; así desaparece por completo la cadena Target (algo más de boilerplate, pero menor superficie de errores).

Ubicación en la arquitectura: DAL/Repository, logging y operación

En una arquitectura por capas (típico: UI – Business – acceso a datos) este mapeo corresponde a la capa de acceso a datos (DAL) o a un Repository. Es importante que el Dataset no se «pase» entre capas: los objetos/DTOs son la interfaz más estable, especialmente si más adelante incorpora REST-APIs o externaliza partes en C# Services.

Para operación y soporte merece la pena el hook de depuración OnDebug. Con él puede registrar, en pruebas o en casos de soporte reproducibles, qué campos Required se mapearon realmente. En sistemas productivos debe estar habilitado de forma selectiva y poder apagarse; de lo contrario el registro se vuelve costoso o genera demasiado volumen de datos.

Uso adecuado del hook de depuración

  • Pruebas unitarias: Comprobar si una sentencia SQL concreta devuelve realmente todos los campos Required.
  • Diagnóstico: Ante problemas de clientes verá de inmediato «campo ausente» vs. «no se pudo convertir el valor».
  • Fases de migración: Al cambiar vistas/nombres de columnas puede mantener listas de candidatos en paralelo hasta que todo se haya migrado.

Cuándo este enfoque falla (y qué es mejor entonces)

El mapeo de dataset a objeto mostrado es robusto cuando la fuente de datos es inestable y usted necesita comportamiento determinista. Suele fallar típicamente en dos situaciones:

  • Cantidades muy grandes (p. ej. exportación masiva): la conversión de Variant y la búsqueda por nombre de campo pueden notarse. Entonces merece la pena un caché de índices de campo precomputado por cada SQL (p. ej. FieldByName una sola vez por dataset, no por fila).
  • Muchos tipos DTO: Si escribe cientos de mapeadores, el código repetitivo se vuelve un problema. Entonces un enfoque basado en RTTI con atributos puede tener sentido —pero sólo si controla estrictamente las salidas de depuración y los convertidores.

Un buen camino intermedio es: resolución de campos y conversión como aquí (explícitas, tolerantes a fallos donde sea necesario), pero con código generado (p. ej. mediante plantillas internas) en lugar de «escrito a mano».

Conclusión: estabilidad mediante reglas explícitas — con límites de aplicación claros

Con datasets heredados con alias, columnas opcionales y semántica histórica de nulos, el mapeo de dataset a objeto tiene éxito sobre todo cuando permanece explícito y capaz de diagnóstico. El plan de mapeo basado en listas de candidatos, Required/Optional y convertidores logra precisamente eso: puede estabilizar cargas heredadas de forma gradual, sin introducir de inmediato un ORM ni normalizar la base de datos «de una sola vez».

Los límites aparecen en escenarios de rendimiento extremo y con un número muy elevado de tipos —entonces necesitará caché o generación automática de código. Para software de negocio típico con procesos maduros, sin embargo, el enfoque es una palanca fiable para volver a desacoplar el acceso a datos y los modelos de dominio y hacerlos mantenibles.

Si para un mapeo legacy concreto (FireDAC, vistas, proliferación de JOINs, semántica de nulos) necesita una segunda opinión o una arquitectura objetivo sólida, el siguiente paso suele ser un breve análisis con ejemplos reproducibles. Contacto:

En el ámbito funcional también juegan un papel importante Delphi Dataset Mapping y Legacy Delphi cuando integraciones, flujos de datos y evolución deben encajar de forma ordenada.

Discutir proyecto o iniciativa de modernización con Net-Base.

Compartir entrada

Compartir esta publicación directamente

LinkedIn, X, XING, Facebook, WhatsApp y correo electrónico están disponibles de inmediato. Para Instagram preparamos el enlace y un texto breve de inmediato.

Correo electrónico

Instagram se abre en una nueva pestaña. El enlace y el texto breve se copian previamente en el portapapeles.