おれのIT日記

2006/04/08 (土)

Perl,CSV

帯に短しタスキに長し


CSVを扱う仕事は、本当に多い。
XMLも多いけど、XMLだと冗長だから、肝心のデータはXMLタグのなかに、CSV形式で埋めます、なんていう、冗談みたいな仕様の仕事もあったりする。

最近はDB2のロードデータをこねくり回す数日が続いたので、相変わらずPerlで楽をしながら、ちゃっちゃか、ちゃっちゃか、やっている。
最近のIT会社の現場では、セキュリティ強化などと称して、開発用PCのセッティングを厳しくされる場面が多い。すると何が困るかというと、管理者権限がないとインストールできないツールが多いことだ。愛用の道具がなければ、スムーズに仕事なんかできないよ。
そんな中、ActivePerlは、ユーザー権限だけでインストールできるので本当に助かる。

これだけ長い間テキストをいじくり回しているくせに、おれは未だに、「これだっ!」というCSV処理が書けていない。
要するに、誤解を招くようなCSVを使わなければ、実用上問題ないのだが、何しろ凝り性なので、イレギュラーなデータの処理方法が、期待と違うと、どうにも気に入らない。例えばこんな風に崩れたデータだ。
1,"2","wao""gao""pao"
Googleの上位にヒットするような正規表現を拾ってきて試すと、最後の"wao以下がすべて無視されてしまう。おれはこれが気に入らない。
かと言って、標準モジュールText::ParseWordsのquotewordsあたりを使うと、次のようなデータの時、解析自体が失敗してしまう。
1,"2","wao\"gao"pao",gaogao!
確かに、こんなの解析できなくてもいいじゃないか!と言われそうだが、テキストというものは何でもありの、汚いデータなのだから、常にきっちりと可能な限りデータを拾ってもらいたいのである。そうでなければ気が済まないのだ。

また、参考書を読むと余計混乱することもある。例えば今回おれは久々に『Perlクックブック第2版 VOLUME1』初版第一刷のレシピ1.20をあらためて読み返してみた。p.55に載っているサンプルを見て「おお、これだ」と独り言を言って、会社でそれを使った。使ったあとで気づいたが、これ、次のようなデータを無視してしまう。
,,,,,,
こういう書き方はよくあって、長さ0の文字列でしかもクォートがない場合である。これを、「フィールド自体がない」と判定されては困るのである。

……しかし、これはクックブックの方が浅はかなのであって、同書も引用している『詳説正規表現第2版』を読めば、ちゃんと、引用した箇所のその先に、上記の対処も書いてあるのだ。クックブックしか持ってないひとで、ここで諦めてしまったひとも、広い日本には一人ぐらい居るかも知れない。そう思うと、罪な記述だ。
とは言え、クックブックは、ちゃんとCPANモジュールの紹介もしてくれるのが有難い。残念ながらおれの場合、例えばText::CSVモジュールを勝手に会社のサーバにインストールするわけには行かないから、今回の解決策にはならないのだけれども。

いくつかのサブルーチンを打ち込んで解析結果を見比べるスクリプトを書いてみたが、どれも気に入らなかった。
#
# テストデータの作成
#
push @testData, <<'EndOfCSV1';
1,"2","wao""gao""pao"
EndOfCSV1

push @testData, <<'EndOfCSV2';
1,"2","wao\"gao"pao",'gaogao!'
EndOfCSV2

push @testData, <<'EndOfCSV3';
Ten Thousand,10000, 2710 ,,"10,000","It's ""10 Grand"", baby",10K
EndOfCSV3

foreach ( @testData ) {
    execTest($_);
}

#
# テストの実行
#
sub execTest () {
    my $csvString = shift;
    chomp($csvString);
    print STDOUT "[$csvString]\n";

    $idx = 1; print                              STDOUT "parse_CSV0\n";
    print STDOUT join ( "\n", map { $idx++.'=['.$_.']' } parse_CSV0($csvString) )."\n";

    $idx = 1; print                              STDOUT "parseCSV0\n";
    print STDOUT join ( "\n", map { $idx++.'=['.$_.']' } parseCSV0($csvString) )."\n";

    $idx = 1; print                              STDOUT "parseCSV1\n";
    print STDOUT join ( "\n", map { $idx++.'=['.$_.']' } parseCSV1($csvString) )."\n";

    $idx = 1; print                              STDOUT "parseCSVX\n";
    print STDOUT join ( "\n", map { $idx++.'=['.$_.']' } parseCSVX($csvString) )."\n";

    print STDOUT "\n";
}

# parse_CSV0
#   ダブルクォーテーションのエスケープは\"であるタイプ用。
#   標準モジュールText::ParseWordsを利用。
#   特徴:
#     項目中に囲み文字が現れただけでは、項目の終わりとは判断せず、有効な区切り文字が続くときにだけ切る…ように見えるが、
#     それは囲み文字が偶数ペアになっているときだけで、不正な囲み文字が単独で現れると混乱して、結果が空になることさえある。
# @param    $   CSV文字列
# @return   @   分解された配列
sub parse_CSV0 {
    use Text::ParseWords;
    my $csvString = shift;
    # quotewords の第二引数を0にした場合、文字列中の""なども除去されてしまうので、いったんそのまま返してもらい、
    # 自前で前後のクォートを外している。
    #my @fields = map { s/""/"/g; s/(?:^"|"$)//g; $_; } quotewords( ',', 1, $csvString );
    #my @fields = map { s/(?:^"|"$)//g; $_; } quotewords( ',', 1, $csvString );
    my @fields = map { s/(?:^"|"$)//g; $_; } quotewords( ',', 1, $csvString );
    return @fields;
}

# parseCSV0
#   ダブルクォーテーションのエスケープが\"であるタイプ用。
#   詳説正規表現からの引き写し。
#   標準モジュールをが利用できない環境でも使えるよう、自前で実装。
#   特徴:
#     項目中に囲み文字が現れると、項目の終わりと判断する。次に有効な区切り文字が見つかるまでの部分は、捨てられる。
# @param    $   CSV文字列
# @return   @   分解された配列
sub parseCSV0 {
    my $csvString = shift;
    my $field, @fields = ();
    while ( $csvString =~ m{
        (?:^|,)
        (?:
            # (1) エスケープありの場合
            "( (?: [^"\\]+|\\.)* )"
            |
            # (2) エスケープなし
            ( [^",]* )
        )
    }gx ) {
        if ( defined $2 ) {
            # (2) エスケープなし
            $field = $2;
        } else {
            # (1) エスケープありの場合
            ( $field = $1 ) =~ s/""/"/g;
        }
        push @fields, $field;
    }
    return @fields;
}

# parseCSV1
#   ダブルクォーテーションのエスケープは""であるタイプ用
#   詳説正規表現からの引き写し。
#   標準モジュールText::ParseWordsでは解析できないので、自前で実装。
#   特徴:
#     項目中に囲み文字が現れると、項目の終わりと判断する。次に有効な区切り文字が見つかるまでの部分は、捨てられる。
# @param    $   CSV文字列
# @return   @   分解された配列
sub parseCSV1 {
    my $csvString = shift;
    my $field, @fields = ();
    while ( $csvString =~ m{
        (?:^|,)
        (?:
            # (1) エスケープありの場合
            "( (?: [^"]|"")* )"
            |
            # (2) エスケープなし
            ( [^",]* )
        )
    }gx ) {
        if ( defined $2 ) {
            # (2) エスケープなし
            $field = $2;
        } else {
            # (1) エスケープありの場合
            ( $field = $1 ) =~ s/""/"/g;
        }
        push @fields, $field;
    }
    return @fields;
}

# parseCSVX
#   ネットで拾った正規表現。比較用。
#   特徴:
#     一見よさそうなのだが、囲み文字が乱れている場合に最後の項目をとりこぼしてしまう。
#     Ten Thousand,10000, 2710 ,,"10,000","It's ""10 Grand"", baby",10K
#     の10kを取りこぼしたり…
# @param    $   CSV文字列
# @return   @   分解された配列
sub parseCSVX {
    my $csvString = shift;
    #my $field, @fields = ();
    return map {/^"(.*)"$/s ? scalar($_ = $1, s/""/"/g, $_) : $_} ($csvString =~ /("[^"]*(?:""[^"]*)*"|[^,]*),/g);
}
たぶん、自分が完全に気に入るカンマ解釈は、正規表現より、一文字ずつ愚直にサーチするロジックの方がスッキリ書けそうなことは判っている。
しかし、それだとまた、面白くないわけだ。