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 回だけ除去する
- fputcsv は fgetcsv の鏡。クォートが必要なセルだけ再クォートしてくれる
問題 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),
};
}
設計判断:
parseIntは(int)キャストより厳密。PHP の(int) "12abc"は12を返す。ソートにおける最悪の振る舞い——汚いデータを隠してしまう。厳密パーサーはnullを返し、null バケットに押し込む- null は実値の後にソート。
"N/A"を 0 に強制変換すると昇順ソートの先頭に来て、最年少が N/A に見える。null を末尾に送れば、クリーンな行が先に来て汚いデータが目に見える filter_varで float バリデーション。(float) "12abc"は12.0、filter_varはfalse- bool 型は意図的に不在。”yes/no/true/false/1/0/Y/N” は兎穴。明示的に
--type col=intかstringを使うべき
問題 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 テスト:
- Comparator(18 件):文字列、int パース、ゴミ拒否、日付混在フォーマット
- Reader(9 件):クォートカンマ、エスケープ引用符、BOM、空行、タブ区切り
- Sorter(8 件):int 昇順、逆順、安定性、unique
- CLI(22 件):全フラグの E2E、クォートカンマのラウンドトリップ
決定的なテストは 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 をソートしているのではない——壊してから出力と呼んでいるだけだ。