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
IDde varias tablas; en el Dataset aparece comoID,ID_1o se renombra mediante alias SQL. - Nulos semánticos:
0significa „desconocido“,'1899-12-30'es „no fecha“,' 'es „no registrado“. - Tipos variables: una View no hace cast; el driver devuelve
ftWideStringen lugar deftInteger. 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
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».
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.
FieldByNameuna 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.