sort -t, は罠だった:CSV を壊さずにソートする PHP CLI を作った

きっかけ

チームの誰かがこう書く:

sort -t, -k2 users.csv > sorted.csv

動く。出力もそれっぽい。3 週間後、顧客が「ユーザー一覧が切れている」と報告してくる。開いてみると alice, "Tokyo, Japan", 30 の行がクォート内のカンマで引き裂かれている。sort -t, は CSV のクォートフィールドを知らない。リテラルカンマで分割する。最初から CSV をソートしていなかった。

📦 GitHub: https://github.com/sen-ltd/csv-sort

スクリーンショット

作ったもの

PHP 8.2 の CLI、約 300 行。fgetcsv で読み、型を意識した比較器でソートし、fputcsv で書く。Composer ランタイム依存ゼロ。Docker イメージ 51 MB。テスト 57 件。

csv-sort users.csv --key age --type age=int

sort -t, が間違える 3 つの問題を正しく扱う。

技術的なポイント

問題 1:クォート内のカンマ

name,city
alice,"Tokyo, Japan"
bob,Osaka

sort -t, -k2 は alice の行を 3 フィールド(alice, "Tokyo, Japan")と見なす。PHP の fgetcsv はクォートカンマ、エスケープ引用符("")、クォート改行、CRLF を正しく扱う:

while (($row = fgetcsv($this->handle, 0, $this->delimiter, '"', '"')) !== false) {
    if ($row === [null]) continue; // 空行:PHP は [null] を返す
    // ...
}

テストで分かったこと: - fgetcsv は空行で [null] を返す(false でも [] でもない) - Excel 出力の UTF-8 BOM(\xEF\xBB\xBF)がヘッダーの最初のセルに付く。1 回だけ除去する - fputcsvfgetcsv の鏡。クォートが必要なセルだけ再クォートしてくれる

問題 2:型の認識がない

name,age
alice,30
bob,25
carol,100

sort -t, -k2 は辞書順で "100" < "25"'1' < '2')。全部文字列だから。

型を意識した比較器を書いた:

public static function compare(string $a, string $b, string $type): int
{
    return match ($type) {
        'int'    => self::compareNullable(self::parseInt($a),   self::parseInt($b)),
        'float'  => self::compareNullable(self::parseFloat($a), self::parseFloat($b)),
        'date'   => self::compareNullable(self::parseDate($a),  self::parseDate($b)),
        default  => strcmp($a, $b),
    };
}

設計判断:

問題 3:カラムごとの逆順

csv-sort users.csv --key name,-age --type age=int

- プレフィックスで「このカラムだけ逆順」。usort のラッパーは短い:

usort($rows, static function (array $a, array $b) use ($keys, $globalDir): int {
    foreach ($keys as $k) {
        $cmp = Comparator::compare($a[$k->index] ?? '', $b[$k->index] ?? '', $k->type);
        if ($cmp !== 0) {
            $dir = $k->reverse ? -1 : 1;
            return $cmp * $dir * $globalDir;
        }
    }
    return 0;
});

PHP 8.0 以降の usort は安定ソート。--unique フラグは安定性を活用して、ソート後に隣接する重複だけを比較する線形スキャンで実装。seen-set もハッシュテーブルも不要。

テスト

57 件の PHPUnit テスト:

決定的なテストは testQuotedCommaRoundTrip"Tokyo, Japan" を含む CSV を書き、フル CLI を通し、行数とクォート文字列の生存を確認する。

おわりに

docker build -t csv-sort https://github.com/sen-ltd/csv-sort.git
docker run --rm -v "$PWD:/work" csv-sort /work/users.csv --key age --type age=int

CSV はルールのあるファイル形式だ。そのルールを知るパーサーを通さなければ、CSV をソートしているのではない——壊してから出力と呼んでいるだけだ。