成長した Delphi システムでは、データセットからオブジェクトへのマッピング が「1フィールド=1プロパティ」というきれいなケースであることは稀です。個別に作られた業務向けソフトウェアでは、ビューのエイリアス列、重複するフィールド名を含むJOIN結果、空の値としての 0 や ' '、今日は VARCHAR で明日は INTEGER を返すような型付きフィールド、検索ダイアログによって単に存在しない列などに出会います。まさにそのあたりで多くのマッパーが破綻します。マッパーは「魔法的」になりデバッグが困難になるか、あるいはあまりに厳格でオプションのフィールド一つで運用が停止してしまうかのどちらかです。
このソース・スニペットは、Delphi 向けの実用的なマッパーを示します。これは意図的に ORMではありません が、主要なレガシーの境界ケースを適切に扱います:一意のフィールド解決、制御された変換、NULLのセマンティクス、オプションフィールド、追跡可能なエラーメッセージ。Data-Access-Layer (DAL、データアクセスをカプセル化する層) やリポジトリパターンに適しており、BDE-Ablosung mit nativer Anbindung(多くのDBに対応したDelphiのデータアクセス用ライブラリ)と組み合わせて使うのに向いています。
既存構造で標準マッピングが失敗する理由
「きれいな」新規設計では滅多に見られない、運用でよく遭遇する典型的な原因をいくつか挙げます:
- 曖昧なフィールド名:JOIN が複数のテーブルの
IDを返し、DataSet 内ではID、ID_1といった名前になったり、SQL エイリアスでリネームされている。 - セマンティックな NULL:
0は「不明」、'1899-12-30'は「日付なし」、' 'は「未入力」を意味する。 - 型の変動:ビューがキャストを行わない、あるいはドライバが
ftWideStringを返してftIntegerではない、といった状況。Variant の変換がエラーの原因になる。 - オプション列:検索ダイアログはフィルタに応じて異なる SELECT リストを使用する。コードはフィールドが常に存在すると仮定している。
- デバッグ可能性:マッピングが RTTI の奥に隠れると、顧客データの障害解析が困難になる(どのフィールド、どの値、どの型か?)。
アプローチ:規約ではなくマッピング・プラン、制御された変換
核となるのは マッピング・プラン です:『プロパティ X はフィールド A または B から来る、必須/任意、コンバータ Y を使う』といったルールのリストです。これによりマッピングは宣言的でありながら、多くの ORM の仕組みのように「見えない」ものにはなりません。さらにマッパーはフィールドごとにフィールド名、データ型、生の値を含む意味のある例外を投げることができます。
重要:我々は意図的に TDataSet からマッピングを行い、具体的な BDE-Ablosung mit nativer Anbindung クラスからではありません。これにより TFDQuery、TClientDataSet、あるいは外部コンポーネントとの互換性が保たれます。
ソーススニペット:レガシー列向けのデバッグ可能なデータセットからオブジェクトへのマッピング
このコードは以下を実装します:
- 優先順位リスト(エイリアス/フォールバック)によるフィールド解決
- 必須/オプションのハンドリング
- コンバータによる NULL のセマンティクス(例:
0 => Null) - コンテキスト付きで安定したエラーメッセージ
- テストやサポート時にマッピング問題を追跡するためのデバッグフック
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: 各 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(‚データセットがアクティブではありません。‘);
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;
// 必須: コンバータ適用後に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;
{ コンバータ }
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 を用いてデータ品質の問題を隠蔽しない。
// フォーマットはレガシーにより異なる可能性がある; 必要ならここで TFormatSettings によりパラメタ化する。
D := ISO8601ToDate(S, False);
Result := D;
end;
end;
end.
Mapper を実務で使う方法(RTTI を使わずに、それでもエレガントに)
Mapper は Assign(TargetMember, Value) コールバック関数を呼び出します。これにより代入処理が明示的になり(デバッグしやすく)、Hot-Path で RTTI 参照を回避できます。実務では、各オブジェクト/DTO(Data Transfer Object、データ転送オブジェクト)ごとに小さな「アサイナー」を作ります。
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 により中央集約的に記述されますが、代入は明示のまま維持されます。レガシー環境では、どのプロパティがどのフィールド名に依存するかが即座に分かるため、完全自動の RTTI マッピングよりもこのトレードオフの方が適切なことが多いです。
前提条件: このアプローチはアクティブな Dataset と現在のレコード位置を前提とします。バッチインポートの場合は外側で while not DS.Eof do をループし、各行ごとに MapCustomer を呼び出してください。
注意点: BLOB や Memo フィールドで VarToStr を使うと問題になることがあるため、これらには専用のコンバータを使ってください。さらに: 「Required」はここではコンバータ適用後を意味します。もし C_TrimToNull が Required フィールドを NULL にするなら、それは意図的な動作です — データ品質はソース側かプロセス側で解決する必要があります。
バリエーション: 文字列の Target の代わりに enum を使いタイプミスを防ぐこともできます。別の方法として Assign 関数を Spec ごとに TProc<Variant> として保持すれば、Target 文字列は完全に不要になります(ボイラープレートは増えますが、エラーのクラスはさらに減ります)。
アーキテクチャ上の位置づけ: DAL/Repository、ロギングと運用
レイヤーアーキテクチャ(典型: UI – Business – Datenzugriff)では、このマッピングはデータアクセス層またはリポジトリに属します。重要なのは Dataset をそのまま「通し渡さない」ことです: オブジェクト/DTO がより安定したインターフェースとなります。特に後で REST-APIs を追加したり、部分を C# サービス に切り出したりする場合に有効です。
運用やサポートにはデバッグフック OnDebug が有用です。これによりテストや再現可能なサポート事例で、どのフィールドが実際にマッピングされたかを記録できます。本番システムでは、意図的に有効化・無効化できるようにしておくべきで、そうしないとログのコストやデータ量が問題になります。
Debugフックの効果的な利用
- ユニットテスト: 特定のSQLステートメントが本当にすべてのRequired-Felderを返すかを検証する。
- 診断: 顧客の問題発生時に「フィールドが存在しなかった」か「値が変換できなかった」かを即座に判別できる。
- 移行フェーズ: Viewや列名を切り替える際に、全移行完了まで候補リストを並行して維持できる。
このアプローチが破綻する時(およびその際に優れた代替)
示したデータセット→オブジェクトのマッピングは、データソースが不安定でも決定論的な挙動が必要な場合に有効です。ただし典型的に次の2つの状況で破綻します:
- 非常に大きな量(例:大量エクスポート):Variantの変換やフィールド名による検索がボトルネックになることがある。その場合はSQLごとの事前計算されたフィールドインデックスのキャッシュが有効(例:
FieldByNameをRowごとではなくDatasetごとに一度だけ実行)。 - 非常に多くのDTO型: 数百のマッパーを実装すると冗長コーディングが問題になる。その場合は属性を用いたRTTIベースのアプローチが有効になり得るが、デバッグ出力やコンバータを厳格に管理できる場合に限る。
良い中間策は、ここで示したような(明示的で、必要な箇所ではエラー耐性のある)フィールド解決と変換を維持しつつ、手書きではなく生成コード(例えば内部テンプレートによる)を用いることです。
結論: 明示的ルールによる安定性 — 明確な適用範囲とともに
エイリアス、オプショナルな列、過去からのNull-Semantikを持つレガシーデータセットでは、データセット→オブジェクトマッピングは特に明示的で診断可能である場合に成功します。候補リスト、Required/Optional、コンバータから成るマッピング計画はまさにそれを実現する:ORMをいきなり導入したりデータベースを一度に正規化したりせずに、負債を段階的に安定化できる。
限界は極端なパフォーマンス要件や非常に多くの型がある場合にあり、その際はキャッシュや自動コード生成が必要になる。しかし成長してきた業務ソフトウェアでは、このアプローチはデータアクセスとドメインモデルを再び分離し保守可能にする信頼できる手段です。
具体的なレガシーマッピング(FireDAC、Views、過剰なJoin、Null-Semantik)についてセカンドオピニオンや信頼できるターゲットアーキテクチャが必要な場合、次のステップは通常、再現可能な例を用いた短い分析です。連絡先:
業務上の文脈では、統合、データフロー、継続的な開発が整合して動作する必要がある場合、Delphi Dataset Mapping およびレガシー Delphi も重要な役割を果たします。