CSV を処理する

 「何を今更」感がありますが、本当に CSV を処理できていますか?



問題提起

 普通、Delphi で CSV 処理と言うと TStringList を 2 つ使うのが一般的です。例えば、Delphi 2006 以前だと...

var
  i, l: Integer;
  SL, dFields: TStringList;
begin
  SL := TStringList.Create;
  dFields := TStringList.Create;
  try
    SL.LoadFromFile('C:\DATA\TEST.CSV');
    for i:=1 to SL.Count-1 do
      begin
        dFields.CommaText := SL[i];
        // ここに処理
      end;
  finally
    dFields.Free;
    SL.Free;
  end;
end;

 こんな感じになります。ところが、やってみると解りますが、フィールドを半角 SP でも分割してくれちゃいます。カンマだけでフィールド分割して欲しいので、Delphi 2006 またはそれ以降では、

var
  i, l: Integer;
  SL, dFields: TStringList;
begin
  SL := TStringList.Create;
  dFields := TStringList.Create;
  try
    dFields.Delimiter := ',';
    dFields.StrictDelimiter := True;
    SL.LoadFromFile('C:\DATA\TEST.CSV');
    for i:=1 to SL.Count-1 do
      begin
        dFields.DelimitedText := SL[i];
        // ここに処理
      end;
  finally
    dFields.Free;
    SL.Free;
  end;
end;

 TStrings.Delimiter / TStrings.DelimitedText / TStrings.StrictDelimiter を利用します。めでたし、めでたし。

 ...とは行かない事があります。うまく行かない例を挙げてみましょう。

 前 2 つは以下のようにして対処できます。

var
  i, l: Integer;
  SL, dFields: TStringList;
begin
  SL := TStringList.Create;
  dFields := TStringList.Create;
  try
    dFields.Delimiter := ',';
    dFields.StrictDelimiter := True;
    SL.LoadFromFile('C:\DATA\TEST.CSV');
    for i:=1 to SL.Count-1 do
      begin
        // 空行だったら抜ける
        if SL[i] = '' then
          Continue;
        // デリミタで分割する
        dFields.DelimitedText := SL[i];
        // 足りないフィールドを埋める (20 フィールドの場合)
        for l:=dFields.Count + 1 to 20 do
          dFields.Add('');
        // ここに処理
      end;
  finally
    dFields.Free;
    SL.Free;
  end;
end;

 さて、フィールドに改行が含まれる場合はどうしましょうか?例えば Excel の場合、フィールド内での改行は 0x0A で、行の改行は 0x0D 0x0A ですが、CSV の仕様としては "フィールド内での改行" / "行の改行" 共に 0x0D 0x0A にする事も可能です。最初、TMemoryStream に読み込んでプリプロセスとしてフィールド中の改行をエスケープしますか?まぁ、場合によってはそれもアリっちゃアリですが...。


そもそも...

 CSV って "Comma Separated Values" の略というのはご存じの事でしょう。カンマ区切りテキストですね。Character Separated Value?そんな後付け設定の事は知りません (ならなんで "文字区切りテキスト" と言わないんだい?)。CSV には他にもお約束があります。  ものは試しです。ちょっと意地の悪い CSV をサンプルで用意してみました (sample_csv.zip)。これをちゃんと処理できますか?

 Excel はこれをちゃんと処理します。逆を言えば、"Excel で編集された CSV はこの意地悪な形式になる事がある"という事です。


"ひとつの解答"

 例外処理で悩まなくて済むようにするには dbGo (ADO Express) を使うのが最も簡単です。Delphi XE Starter をお持ちの方には何の解決にもなっていなくてスミマセン。

 "例の意地悪な CSV (sample_csv.zip)" を読み込ませてみましょう。フォームには TStringGrid と TButton が一つづつ貼ってあるものとします。

uses
 ..., ADODB;

procedure TForm1.Button1Click(Sender: TObject);
var
  i: Integer;
  ADOC: TADOConnection;
  ADOQ: TADOQuery;
  ExtProps: String;
  FullPathName, FilePath, FileName: String;
begin
  // ファイル名の設定
  FullPathName := 'C:\DATA\SAMPLE.CSV';
  FilePath := ExcludeTrailingPathDelimiter(ExtractFilePath(FullPathName));
  FileName := ExtractFilename(FullPathName);
  // dbGo の設定
  ADOC := TADOConnection.Create(Self);
  ADOQ := TADOQuery.Create(Self);
  try
    // コネクション文字列の生成
    // (OLE DB プロバイダ = MSDASQL)
    ExtProps := '';
    ExtProps := ExtProps + 'DRIVER={Microsoft Text Driver (*.txt; *.csv)};'// ドライバ名
    ExtProps := ExtProps + Format('DefaultDir=%s;', [FilePath]);             // CSV のあるフォルダ
    ExtProps := ExtProps + 'HDR=Yes;';                                       // 1 行目がヘッダか?
    ExtProps := Format('Extended Properties="%s";', [ExtProps]);
    ADOC.ConnectionString := 'Provider=MSDASQL;' + ExtProps;                 // OLE DB プロバイダ
    ADOQ.Connection := ADOC;
    // 接続
    ADOC.Connected := True;
    // CSV に対してクエリ発行
    with ADOQ do
      begin
        SQL.Clear;
        SQL.Add('Select * From "' + FileName + '"');
        Open;
        // StrinGrid の行数を設定
        if IsEmpty then
          StringGrid1.RowCount := 2
        else
          StringGrid1.RowCount := RecordCount + 1;
        // ヘッダを読む
        for i:=0 to Fields.Count -1 do
          StringGrid1.Cells[i, 0] := ADOQ.Fields[i].FieldName;
        // データを読む
        i := 1;
        while not EOF do
          begin
            StringGrid1.Cells[0, i] := FieldByName('FIELD_01').AsString;
            StringGrid1.Cells[1, i] := FieldByName('FIELD_02').AsString;
            StringGrid1.Cells[2, i] := FieldByName('FIELD_03').AsString;
            StringGrid1.Cells[3, i] := FieldByName('FIELD_04').AsString;
            StringGrid1.Cells[4, i] := FieldByName('FIELD_05').AsString;
            Inc(i);
            Next;
          end;
        Close;
      end;
    // 切断
    ADOC.Connected := False;
  finally
    ADOQ.Free;
    ADOC.Free;
  end;
end;

 理解しやすいようにソースコードは長めになっていますが、そんなに難しいものではありません。dbGo のコンポーネントをフォームに貼るのであればもっと簡単になるハズです。

 実行結果がコレです。フィールド中の空白/カンマ/エスケープされたクォーテーション/改行、省略されたフィールド等を考慮したコーディングは一切していませんが、行もフィールドもズレる事なく読み込めているのがお判り頂けると思います。

 「複数行になってないじゃねーか」と仰るかもしれませんが、StringGrid が改行で表示してくれないからこうなっています。このままだと "FieldByName('FIELD_02').AsString" で取得できるデータは 0x0A 区切りになっていますので、実用的にするためには StringReplace() で 0x0D,0x0A に置換するか、TStringList に放り込んで取り出すといった処理が必要ですが、ここまでやれば残った処理は些細なものです。少なくとも行とフィールドがズレてさえなければ後はどうにでもできます。

 CSV に対して SQL を投げているので、当然 Where 句も使えますし、Order By 句も使えます。

        SQL.Clear;
        SQL.Add('Select * From "' + FileName + '"');
        SQL.Add('Where                           ');
        SQL.Add('  [FIELD_01] > :_FIELD_01       ');
        Parameters.ParamByName('_FIELD_01').Value := 3;
        Open;
        ...

 フィールド名は "[]" で括らなければなりません。パラメータも普通に使えます。実行結果は以下のようになります。

 条件を絞って取り込んだり (空行を弾くとか)、フィールドを入れ替えて取り込んだり、フィールドを並べ替えて取り込んだりできます。同様のテキスト処理は BDE でも可能ですが、これだけのためだけに BDE を配布しなくてはならないのはどうかと思いますのでオススメはしません。

 また、CSV 処理に使える "OLE DB プロバイダ" には、

 がありますが、このうち Jet に関しては 64bit サポートがありません (サポート予定もありません) ので注意が必要です。ちなみに...同様のコードで、コネクション文字列を変更するだけで XLS や XLSX を読み出す事も可能です。ADO コネクション文字列の詳細に関しては NYORO PRESS さんの "ADO (ActiveX Data Object)" に詳細が書かれています。


考察

 TStringList の挙動を知るにつれ CSV 処理クラスを書きたくなる衝動に駆られてしまうと思います。そしてクラスを書き上げていざ使ってみる段になって、「これだったら dbGo (ADO Express) で処理する方が手っ取り早くね?」と感じてしまうのです。

 先に書いたように dbGo (ADO Express) で処理すれば、抽出 / 並び替えを先に行ってから必要な部分だけを取り込めますので、殆どの場合、処理そのものを書き直さず SQL だけ書き換えれば済みますからね。

 プログラマとしてはこの手段を採る事に少々釈然としない感がありますが、「データを CSV でくれ」と言われた場合には 9 割以上の確率で Excel によるデータ入力が行われるのが実情であり、"Excel が吐く CSV と親和性が高いのは ADO による処理" というのもまた確かなのです。

 ...ただ、Excel が吐く CSV はフィールド中の改行が 0x0A、行改行が 0x0D,0x0A である事は先にも書きましたが、CSV の仕様をクラスプラットフォームで考えた場合、

 というのもアリですので、"プラットフォームに依存しない CSV 処理クラス (または DBX4 ドライバ等)" が欲しいのは欲しいんですよね...。


ついでに TSV

 TSV は "Tab Separated Values" の略です。タブ区切りテキストです...が、TSV もひっくるめて CSV と呼ぶ事があります ("Character Separated Values" という苦しい言い訳が用意されています)。

 TSV のフォーマットはフィールドセパレータが単純にタブに置き換わっただけです...なので、フィールド中にカンマが存在する場合には CSV 同様に前後をクォート文字で括る必要があります。

 タブ区切りテキストをクリップボードに読み込ませると、そのまま Excel や Ooo-Calc に貼り付ける事ができるため、場合によっては CSV よりも重宝する事があります。ファイルへのエクスポートは CSV 形式、クリップボードへは TSV 形式で出力するようにしておくとアプリケーションの応用範囲が広がります。


 BACK