Net-Base מגזין

10.05.2026

מיפוי מערך נתונים לאובייקט עבור מבני-מורשת לא שגרתיים: יציב, ניתן לניפוי שגיאות, ללא קסמי ORM

כאשר מערכי נתונים ישנים (Legacy-Datasets) נבנו לאורך זמן, מנגנוני מיפוי סטנדרטיים נכשלים לעתים קרובות מול עמודות כינוי, תערובות טיפוסים ומבני JOIN משתנים. קטע קוד זה מציג מיפוי יציב וניתן לניפוי שגיאות ממערך-נתונים לאובייקט בDelphi: עם תוכנית מיפוי, ממירים וסמנטיקת null.

10.05.2026

במערכות Delphi שצמחו לאורך זמן, מיפוי Dataset לאובייקט הוא לעתים נדירות המקרה הנקי של „שדה אחד = מאפיין אחד“. בתוכנות ארגוניות מותאמות תיתקלו במקום זאת בעמודות-כינוי מתוך Views, בתוצאות Join עם שמות שדה כפולים, בערכים „ריקים“ כ-0 או ' ', בעמודות עם טיפוס משתנה שמחזירות היום VARCHAR ומחר INTEGER, ובעמודות שלפעמים, בהתאם לדיאלוג החיפוש, פשוט אינן קיימות. בדיוק שם רבים מכלי המיפוי נכשלים: או שהם הופכים ל“קסומים“ (ולכן קשים לניפוי שגיאות), או שהם כה קפדניים שפריט אופציונלי אחד עוצר את הפעולה.

קטע מקור זה מציג כלי מיפוי פרגמטי ל-Delphi, שהוא במכוון לא ORM, אך מטפל באופן נקי במקרי הקצה העיקריים של Legacy: פתרון חד-משמעי של שדות, המרת טיפוסים מבוקרת, סמנטיקת Null, שדות אופציונליים והודעות שגיאה ברות-מעקב. הוא מתאים ל-Data-Access-Layer (DAL, כלומר שכבה שעוטפת גישת נתונים) או ל-Repository-Patterns – ומתחבר היטב ל-BDE-Ablosung עם חיבור ילידי (ספריית גישת הנתונים של Delphi עבור מספר DBs).

מדוע מיפוי סטנדרטי נכשל במבני מורשת

כמה סיבות אופייניות מהשדה שמעט נראות בעיצוב מחדש „נקי“:

  • שמות שדות רב-משמעיים: Join מחזיר ID מכמה טבלאות; ב-Dataset השם יכול להופיע כ-ID, ID_1 או להיות משונה בעזרת SQL-Alias.
  • Nulls סמנטיים: 0 משמעותו „לא ידוע“, '1899-12-30' הוא „אין תאריך“, ' ' הוא „לא מטופל“.
  • טיפוסים תנודתיים: View לא מבצע cast; הדרייבר מחזיר ftWideString במקום ftInteger. המרת Variant הופכת למקור לתקלות.
  • עמודות אופציונליות: דיאלוג חיפוש משתמש, לפי המסננים, ברשימות SELECT שונות. הקוד מצפה שהעמודות יהיו „תמיד“.
  • יכולת דיבוג: כאשר המיפוי נעלם בתוך RTTI, איתור שגיאות על נתוני לקוח קשה (איזה שדה, איזה ערך, איזה טיפוס?).

גישה: תוכנית מיפוי במקום קונבנציה, עם המרה מבוקרת

הליבה היא תוכנית מיפוי: רשימת כללים „מאפיין X מגיע מהשדה A או B, הוא אופציונלי/נדרש, משתמש בממיר Y“. כך המיפוי נשאר דקלרטיבי, אבל לא „בלתי נראה“ כמו במנגנוני ORM רבים. בנוסף יכול כלי המיפוי ליידע על חריגה משמעותית לכל שדה, כולל שם השדה, טיפוס הנתונים והערך הגולמי.

חשוב: אנו ממפים במכוון מתוך TDataSet, לא מתוך מחלקת BDE-Ablosung mit nativer Anbindung קונקרטית. כך זה נשאר תואם ל-TFDQuery, TClientDataSet או לרכיבים חיצוניים.

קטע מקור: מיפוי Dataset-לאובייקט בר-דיבוג לעמודות מורשת

הקוד מממש:

  • פתרון שדות באמצעות רשימת עדיפויות (Aliases/Fallbacks)
  • טיפול בשדות נדרשים/אופציונליים
  • סמנטיקת Null באמצעות ממירים (לדוגמה 0 => Null)
  • הודעות שגיאה יציבות עם הקשר
  • הוק דיבוג שיאפשר לעקוב אחרי בעיות מיפוי בבדיקות או במקרי תמיכה
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);

  // המירה מקבלת 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: אפשרות אופציונלית, ללא 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 אינו פעיל.');

  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('שגיאת מיפוי: שדה Required עבור %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 מסומן כ-Required, אך הערך הוא 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 קורא לפונקציית callback Assign(TargetMember, Value). זה שומר את ההקצאה מפורשת (ולכן קל לדיבוג) ומונע גישות ל-RTTI ב-Hot-Path. בפועל תבנו עבור כל אובייקט/DTO (Data Transfer Object, כלומר אובייקט להעברת נתונים) „Zuweiser“ קטן.

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;

Zweck: המיפוי מתואר באופן מרכזי במקום אחד (Specs), אך ההקצאה נשארת מפורשת. במצבי Legacy זו לרוב החלטת trade-off מועדפת על פני מיפוי RTTI אוטומטי, כי כך רואים מייד איזו Property תלויה באיזה שמות שדות.

Randbedingungen: הגישה מניחה Dataset פעיל ומיקום שורה נוכחי. לייבוא אצווה יש לעבור מבחוץ על while not DS.Eof do ולקרוא ל-MapCustomer עבור כל שורה.

Stolperfallen: שימו לב ל-VarToStr אצל BLOBs או שדות Memo; שם כדאי להשתמש בממירים מותאמים. ובנוסף: „Required“ כאן משמעותו לאחר הממיר. אם C_TrimToNull הופך שדה Required ל-Null, זה מכוון – איכות הנתונים צריכה להיפתר במקור או בתהליך.

Varianten: במקום Targets מסוג מחרוזת תוכלו להשתמש ב-enum כדי למנוע טעויות הקלדה. לחלופין ניתן לאחסן את פונקציית Assign לכל Spec כ-TProc<Variant>, ואז מחרוזת ה-Target נעלמת לחלוטין (יותר Boilerplate, אך פחות סוגי שגיאות).

Einordnung in Architektur: DAL/Repository, Logging und Betrieb

באדריכלות שכבות (טיפית: UI – Business – Datenzugriff) המיפוי הזה שייך לשכבת גישת הנתונים או ל-Repository. חשוב שה-Dataset לא „יועבר“ מעבר לשכבות: אובייקטים/DTOs הם ממשק יציב יותר, במיוחד אם לאחר מכן תוסיפו APIs של REST או תוציאו חלקים ל-C# Services.

לצורך תפעול ותמיכה שווה להשתמש ב‑Debug‑Hook OnDebug. בעזרתו תוכלו בבדיקות או במקרי תמיכה שניתנים לשחזור לתעד אילו שדות אכן מופו. במערכות פרודוקטיביות יש להפעילו בצורה ממוקדת וניתנת לכיבוי, אחרת הלוגינג יהפוך ליקר מדי או ייצור עומס נתונים.

שימוש מועיל ב‑Debug‑Hook

  • Unit-Tests: לבדוק האם משפט SQL מסוים מספק באמת את כל שדות ה‑Required.
  • Diagnose: במקרה של בעיות אצל לקוח תראו מיד „השדה לא היה שם“ מול „לא ניתן להמיר את הערך“.
  • Migrationsphasen: בעת שינוי Views/שמות עמודות תוכלו לתחזק במקביל רשימות מועמדים עד שהכל הועבר.

מתי הגישה הזו קורסת (ומה עדיף אז)

המיפוי שמציגים כאן מ‑Dataset לאובייקט חזק כשהמקור נתונים לא יציב ועדיין דרושה התנהגות דטרמיניסטית. הוא נוטה להיכשל בדרך כלל בשתי מצבים:

  • כמויות מאוד גדולות (למשל ייצוא המוני): המרת Variant וחיפוש לפי שם שדה עלולים להיות כבדים. במקרה כזה כדאי לבצע מטמון אינדקס שדות מחושב מראש לכל SQL (למשל FieldByName פעם אחת לכל Dataset, לא לכל שורה).
  • מספר רב של טיפוסי DTO: אם תצטרכו לכתוב מאות מפות (Mapper), קוד שגרתי יהפוך לנושא משמעותי. אז גישה מבוססת RTTI עם אטריבוטים יכולה להיות מתאימה — אך רק אם תשלוטו בקפדנות ביציאות Debug ובממירים.

דרך אמצע טובה היא: פתרון שדות והמרה כפי שמתואר כאן (מפורש, סובל שגיאות במקום הצורך), אבל עם קוד שנוצר באופן אוטומטי (למשל באמצעות תבניות פנימיות) במקום קוד „כתוב ידנית“.

מסקנה: יציבות באמצעות חוקים מפורשים – עם גבולות שימוש ברורים

ב‑Legacy‑Datasets עם Aliases, עמודות אופציונליות וסמנטיקת NULL היסטורית, מיפוי Dataset לאובייקט מצליח בעיקר כאשר הוא נשאר מפורש ו‑ניתן לאבחון. תוכנית המיפוי המורכבת מרשימות מועמדים, Required/Optional וממירים יוצרת בדיוק את זה: תוכלו לייצב עוולות היסטוריות בהדרגה מבלי להטמיע מיד ORM או לנרמל את בסיס הנתונים „בבת אחת“.

הגבולות נמצאים בביצועים קיצוניים ובכמות רבה של טיפוסים — אז תזדקקו למטמון או ליצירת קוד אוטומטית. עבור תוכנת עסק טיפוסית עם תהליכים שהתפתחו לאורך זמן, הגישה מהווה מנוף מהימן כדי להפריד מחדש את גישת הנתונים ממודלי הדומיין ולהפוך אותם לתחזוקתיים.

אם אתם זקוקים לדעת נוספת או לארכיטקטורת יעד מהימנה עבור מיפוי Legacy קונקרטי (FireDAC, Views, Join-Wildwuchs, Null-Semantik), השלב הבא ברוב המקרים הוא ניתוח קצר עם דוגמאות שניתן לשחזר. יצירת קשר:

בהקשר מקצועי משחקים גם Delphi Dataset Mapping ו‑Legacy Delphi תפקיד חשוב, כאשר אינטגרציות, זרימות נתונים ופיתוח המשך חייבים לפעול בשילוב מסודר.

לדון בפרויקט או במהלך מודרניזציה עם Net-Base.

שתף פוסט

לשתף את הפוסט הזה ישירות

LinkedIn, X, XING, Facebook, WhatsApp ודוא"ל זמינים מיידית. עבור Instagram אנו מכינים קישור וטקסט קצר באופן מיידי.

דוא״ל

אינסטגרם נפתח בכרטיסייה חדשה. הקישור וטקסט קצר מועתקים מראש ללוח.