Net-Base Περιοδικό

10.05.2026

Χαρτογράφηση Dataset σε Αντικείμενο για ασυνήθιστες κληρονομημένες δομές: σταθερή, εύκολα αποσφαλματώσιμη, χωρίς μαγεία ORM

Όταν τα παλαιά σύνολα δεδομένων έχουν προκύψει ιστορικά, οι τυπικοί mapper συχνά αποτυγχάνουν μπροστά σε alias στήλες, ανάμεικτους τύπους και μεταβαλλόμενες δομές JOIN. Αυτό το απόσπασμα πηγαίου κώδικα παρουσιάζει ένα ανθεκτικό, ευδιάγνωστο mapping από σύνολο δεδομένων σε αντικείμενο σε Delphi: με σχέδιο αντιστοίχισης, μετατροπείς, σημασιολογία null...

10.05.2026

Σε παλαιωμένα Delphi-συστήματα το Dataset-σε-Αντικείμενο Mapping σπάνια είναι η καθαρή περίπτωση «ένα πεδίο = μία property». Σε εξατομικευμένο λογισμικό επιχειρήσεων συναντάτε αντ’ αυτού στήλες ψευδωνύμων από views, αποτελέσματα join με διπλά ονόματα πεδίων, «κενές» τιμές ως 0 ή ' ', τυποποιημένα πεδία που σήμερα επιστρέφουν VARCHAR και αύριο INTEGER, και στήλες που ανάλογα με τον διάλογο αναζήτησης απλώς λείπουν. Εκεί αποτυγχάνουν πολλοί mapper: είτε γίνονται «μαγικοί» (και άρα δύσκολα για debugging), είτε είναι τόσο αυστηροί που ακόμα και ένα προαιρετικό πεδίο σταματά τη λειτουργία.

Αυτό το απόσπασμα κώδικα δείχνει έναν πρακτικό mapper για Delphi, ο οποίος σκόπιμα δεν είναι ORM, αλλά χειρίζεται με σαφήνεια τις πιο σημαντικές legacy περιπτώσεις: αποσαφήνιση πεδίων, ελεγχόμενες μετατροπές, σημασιολογία null, προαιρετικά πεδία και ιχνηλάσιμα μηνύματα σφάλματος. Είναι κατάλληλος για Data-Access-Layer (DAL, δηλαδή μια στρώση που καλύπτει την πρόσβαση σε δεδομένα) ή για repository-patterns – και συνδυάζεται εύκολα με την BDE-Ablosung με nativer Anbindung (τη βιβλιοθήκη πρόσβασης δεδομένων των Delphi για πολλές DBs).

Γιατί το standard-mapping αποτυγχάνει σε παλαιές δομές

Μερικές τυπικές αιτίες από την παραγωγική λειτουργία που σπάνια εμφανίζονται σε ένα «καθαρό» νέο σχεδιασμό:

  • Αμφίσημα ονόματα πεδίων: Το join επιστρέφει το ID από πολλούς πίνακες· στο Dataset εμφανίζεται τότε ως ID, ID_1 ή έχει μετονομαστεί με SQL-Alias.
  • Σημασιολογικά Null: 0 σημαίνει «άγνωστο», '1899-12-30' είναι «όχι ημερομηνία», ' ' είναι «μη καταχωρημένο».
  • Ασταθείς τύποι: Ένα View δεν κάνει cast· ο οδηγός επιστρέφει ftWideString αντί ftInteger. Οι μετατροπές Variant γίνονται πηγή σφαλμάτων.
  • Προαιρετικές στήλες: Ένας διάλογος αναζήτησης χρησιμοποιεί ανάλογα με το φίλτρο διαφορετικές λίστες SELECT. Ο κώδικας όμως περιμένει τα πεδία «πάντα».
  • Εντοπισμός σφαλμάτων: Αν το mapping εξαφανίζεται μέσα στο RTTI, ο εντοπισμός σφαλμάτων σε δεδομένα πελατών γίνεται δύσκολος (ποιο πεδίο, ποια τιμή, ποιος τύπος;).

Πρόσέγγιση: Mapping-Plan αντί για σύμβαση, με ελεγχόμενη μετατροπή

Ο πυρήνας είναι ένα Mapping-Plan: μια λίστα κανόνων «η property X προέρχεται από το πεδίο A ή B, είναι optional/required, χρησιμοποιεί τον converter Y». Έτσι το mapping παραμένει δηλωτικό, αλλά όχι «αόρατο» όπως σε πολλούς ORM-μηχανισμούς. Επιπλέον ο mapper μπορεί ανά πεδίο να ρίξει μια κατατοπιστική εξαίρεση, συμπεριλαμβανομένου του ονόματος του πεδίου, του τύπου δεδομένων και της ακατέργαστης τιμής.

Σημαντικό: Χαρτογραφούμε σκόπιμα από TDataSet, όχι από μια συγκεκριμένη κλάση BDE-Ablosung mit nativer Anbindung. Έτσι διατηρείται συμβατότητα με TFDQuery, TClientDataSet ή και με εξωτερικές συνιστώσες.

Απόσπασμα κώδικα: Εντοπίσιμο Dataset-σε-Αντικείμενο Mapping για legacy στήλες

Ο κώδικας υλοποιεί:

  • Επίλυση πεδίων μέσω λίστας προτεραιότητας (Aliases/Fallbacks)
  • Χειρισμός Required/Optional
  • Σημασιολογία null μέσω converter (π.χ. 0 => Null)
  • Σταθερά μηνύματα σφάλματος με συμφραζόμενα
  • Ένα debug-hook για να μπορεί να ιχνηλατηθεί ένα πρόβλημα mapping σε δοκιμή ή σε περίπτωση υποστήριξης

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: καλεί τον setter για κάθε Spec. Χωρίς RTTI: η ρητή ανάθεση είναι πιο εύκολη στον εντοπισμό σφαλμάτων.
procedure MapOne(DS: TDataSet; const Specs: TArray<TFieldSpec>;
const Assign: TProc<string, Variant>);
end;

// Βοηθητικοί μετατροπείς
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· λαμβάνει υπόψη το 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 μετά τη μετατροπή είναι σφάλμα (πιο συχνό απ‘ ό,τι φαίνεται).
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 καλεί μια Assign(TargetMember, Value)-callback συναρτηση. Αυτό διατηρεί την ανάθεση ρητή (και επομένως ευκολότερη στο debugging) και αποφεύγει προσβάσεις RTTI στο hot-path. Στην πράξη κατασκευάζετε για κάθε αντικείμενο/DTO (Data Transfer Object, δηλαδή αντικείμενο μεταφοράς δεδομένων) έναν μικρό «εκχωρητή».

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;

Σκοπός: Η αντιστοίχιση περιγράφεται κεντρικά σε ένα σημείο (Specs), αλλά η ανάθεση παραμένει ρητή. Σε Legacy περιπτώσεις αυτή είναι συχνά η προτιμητέα trade-off απόφαση σε σύγκριση με ένα πλήρως αυτόματο RTTI-mapping, διότι βλέπετε άμεσα ποια ιδιότητα εξαρτάται από ποιο όνομα πεδίου.

Προϋποθέσεις: Η προσέγγιση προϋποθέτει ένα ενεργό Dataset και μια τρέχουσα θέση εγγραφής. Για batch εισαγωγές επαναλάβετε εξωτερικά με while not DS.Eof do και καλέστε το MapCustomer για κάθε σειρά.

Παγίδες: Προσέξτε τη χρήση του VarToStr σε BLOBs ή πεδία Memo· εκεί θα πρέπει να χρησιμοποιήσετε δικούς σας μετατροπείς. Επίσης: «Required» εδώ σημαίνει μετά τον μετατροπέα. Αν το C_TrimToNull θέσει ένα Required πεδίο σε Null, αυτό είναι εκ προθέσεως — η ποιότητα των δεδομένων πρέπει να διευκρινιστεί στην πηγή ή στη ροή επεξεργασίας.

Παραλλαγές: Αντί για string-targets μπορείτε να χρησιμοποιήσετε ένα enum για να αποκλείσετε σφάλματα πληκτρολόγησης. Εναλλακτικά, η Assign-συνάρτηση μπορεί να αποθηκευτεί ανά Spec ως TProc<Variant>, οπότε ο target-string παύει να υπάρχει εντελώς (περισσότερο boilerplate, αλλά ακόμα μικρότερος χώρος για σφάλματα).

Ενσωμάτωση στην αρχιτεκτονική: DAL/Repository, Logging και λειτουργία

Σε μια αρχιτεκτονική πολλαπλών στρωμάτων (τυπικά: UI – Business – πρόσβαση δεδομένων) αυτή η αντιστοίχιση ανήκει στη στρώση πρόσβασης δεδομένων ή σε ένα repository. Σημαντικό είναι να μην «περνάει» το Dataset: Αντικείμενα/DTOs είναι η πιο σταθερή διεπαφή, ειδικά αν αργότερα θα προσθέσετε REST-APIs ή θα εξωτερικοποιήσετε μέρη σε C# Services.

Για τη λειτουργία και την υποστήριξη αξίζει ο Debug-Hook OnDebug. Με αυτόν μπορείτε σε τεστ ή σε αναπαραγόμενες περιπτώσεις υποστήριξης να καταγράψετε ποια πεδία πράγματι χαρτογραφήθηκαν. Σε παραγωγικά συστήματα πρέπει να είναι στοχευμένο και απενεργοποιήσιμο, αλλιώς το logging γίνεται πολύ δαπανηρό ή υπερβολικά δεδοματοβλαβές.

Σωστή χρήση του Debug-Hook

  • Unit-Tests: Ελέγξτε αν ένα συγκεκριμένο SQL-Statement όντως επιστρέφει όλα τα υποχρεωτικά πεδία.
  • Διάγνωση: Σε προβλήματα πελατών βλέπετε αμέσως «το πεδίο δεν υπήρχε» vs. «η τιμή δεν μπορούσε να μετατραπεί».
  • Φάσεις μετανάστευσης: Κατά την αλλαγή Views/ονόματων στηλών μπορείτε να διατηρείτε παράλληλες λίστες υποψηφίων μέχρι να ολοκληρωθεί η μεταφορά.

Πότε αποτυγχάνει αυτή η προσέγγιση (και τι είναι καλύτερο τότε)

Η παρουσιαζόμενη απεικόνιση Dataset σε αντικείμενο είναι ισχυρή όταν η πηγή δεδομένων είναι ασταθής και χρειάζεστε παρ‘ όλα αυτά ντετερμινιστική συμπεριφορά. Συνήθως αποτυγχάνει σε δύο καταστάσεις:

  • Πολύ μεγάλοι όγκοι (π.χ. μαζικό export): Η μετατροπή Variant και η αναζήτηση ανά όνομα πεδίου μπορεί να γίνουν αισθητές. Τότε αξίζει ένα προκαθορισμένο caching ευρετηρίου πεδίου ανά SQL (π.χ. FieldByName μία φορά ανά Dataset, όχι ανά εγγραφή).
  • Πολύ πολλοί τύποι DTO: Αν γράφετε εκατοντάδες mapper, το boilerplate γίνεται πρόβλημα. Τότε μια προσέγγιση βασισμένη σε RTTI με attributes μπορεί να είναι λογική — αλλά μόνο αν ελέγχετε αυστηρά τις debug-εκτυπώσεις και τους μετατροπείς.

Ένας καλός ενδιάμεσος δρόμος είναι: επίλυση πεδίων και μετατροπή όπως εδώ (ρητά, ανεκτική στα σφάλματα όπου χρειάζεται), αλλά με παραγόμενο κώδικα (π.χ. μέσω εσωτερικών templates) αντί για «χειροποίητο».

Συμπέρασμα: Σταθερότητα μέσω ρητών κανόνων — με σαφή όρια εφαρμογής

Σε Legacy-Datasets με aliases, προαιρετικές στήλες και ιστορική Null-Semantik η απεικόνιση Dataset σε αντικείμενο είναι επιτυχής κυρίως όταν παραμένει ρητή και διαγνώσιμη. Το σχέδιο mapping από λίστες υποψηφίων, Υποχρεωτικό/Προαιρετικό και μετατροπείς πετυχαίνει ακριβώς αυτό: μπορείτε να σταθεροποιήσετε σταδιακά τα παλαιά βάρη χωρίς να εισάγετε αμέσως ένα ORM ή να κανονικοποιήσετε τη βάση «σε μία φάση».

Τα όρια υπάρχουν σε περιπτώσεις εξαιρετικών απαιτήσεων απόδοσης και σε περιπτώσεις με πολύ πολλούς τύπους — τότε χρειάζεστε caching ή αυτοματοποιημένη παραγωγή κώδικα. Για τυπικό επιχειρησιακό λογισμικό με ανεπτυγμένες διαδικασίες, η προσέγγιση παραμένει όμως ένας αξιόπιστος μοχλός για να αποσυνδέσετε την πρόσβαση στα δεδομένα από τα domain μοντέλα και να τα κάνετε συντηρήσιμα.

Αν σε ένα συγκεκριμένο Legacy-Mapping (FireDAC, Views, Join-Wildwuchs, Null-Semantik) χρειάζεστε δεύτερη γνώμη ή μια τεκμηριωμένη στοχευμένη αρχιτεκτονική, το επόμενο βήμα είναι συνήθως μια σύντομη ανάλυση με αναπαραγόμενα παραδείγματα. Kontakt:

Στο τεχνικό πλαίσιο παίζουν επίσης ρόλο τα Delphi Dataset Mapping και Legacy Delphi όταν ενσωματώσεις, ροές δεδομένων και περαιτέρω ανάπτυξη πρέπει να συνεργάζονται καθαρά.

Συζητήστε έργο ή σχέδιο εκσυγχρονισμού με Net-Base.

Κοινοποίηση δημοσίευσης

Μοιραστείτε αυτήν την ανάρτηση απευθείας

LinkedIn, X, XING, Facebook, WhatsApp und E‑Mail είναι άμεσα διαθέσιμα. Για το Instagram ετοιμάζουμε άμεσα τον σύνδεσμο και το σύντομο κείμενο.

Ηλεκτρονικό ταχυδρομείο

Το Instagram ανοίγει σε μια νέα καρτέλα. Ο σύνδεσμος και το σύντομο κείμενο αντιγράφονται πρώτα στο πρόχειρο.