🐘

[PHP] league/csvで列名がユニークじゃないCSVファイルを強引に処理する方法

2020/02/23に公開

こんにちは、たつきちです。

エンジニア歴12年ぐらいで今はベンチャー企業のCTOをしています。

この記事では、PHPのCSV操作ライブラリ league/csv で「列名がユニークでないCSVファイル」を読み込む方法について解説していきます。

ぜひ最後までお付き合いください。

league/csvは列名がユニークなCSVしか読み込めない

こちらの記事 でもご紹介した、PHPのCSV操作ライブラリ league/csv ですが、実は 「列名がユニークなCSVしか読み込めない」 という特徴があります。

例えば、以下のようなCSVファイルがあるとしましょう。

このCSVは、B列とC列の列名がどちらも 列名2 となっており、列名がユニークになっていません。

このCSVファイルを以下のようなコードで読み込もうとしてみます。

<?php
use League\Csv\Reader;

$csv = Reader::createFromPath('/path/to/your/csv/file.csv', 'r');
$csv->setHeaderOffset(0);

foreach ($csv as $row) {
    var_dump($row);
}

すると、以下のようなエラーとなります。

Uncaught League\Csv\SyntaxError: The header record must be empty or a flat array with unique string values

「ヘッダー行は空もしくはユニークな値を持った一次元配列である必要があります」と読めます。

コードを見てみる と、ヘッダー行の値がユニークでない場合は有無を言わさずエラーにしていますね。

どうやらleague/csvはデータベースのように厳密なデータ構造を持ったCSVを前提としているようで、列名がユニークでないようなファジーなCSVは触らせてもらえないようです。

強引に解決してみる

とは言っても、ぶっちゃけ世の中のCSVファイルはそんな厳密なデータ構造を守っているものばかりではありません。

実際、僕も今回仕事であるシステムのエクスポートCSVをleague/csvで読み込もうとしてこの問題に遭遇しました。

というわけで、ちょっと強引な方法で無理やり列名がユニークでないCSVをleague/csvで読み込めるようにしました。

やり方は簡単で、デフォルトで用意されている Reader クラスをそのまま使わずに、継承して getHeader() メソッドを改変してあげれば読み込めるようになります。

<?php
// FuzzyCsv.php

namespace My\App;

use League\Csv\Reader;

class FuzzyCsv extends Reader
{
    public function getHeader(): array
    {
        parent::getHeader();

        foreach ($this->header as $key => $value) {
            if (array_count_values($this->header)[$value] > 1) {
                $this->header[$key] = $value . '_' . $key;
            }
        }

        return $this->header;
    }
}
<?php
- use League\Csv\Reader;
+ use My\App\FuzzyCsv;

- $csv = Reader::createFromPath('/path/to/your/csv/file.csv', 'r');
+ $csv = FuzzyCsv::createFromPath('/path/to/your/csv/file.csv', 'r');
$csv->setHeaderOffset(0);

foreach ($csv as $row) {
    var_dump($row);
}

こうすると、エラーになることなく、以下のような出力を得ることができます。

array (size=4)
  '列名1' => string 'ほげ' (length=6)
  '列名2_1' => string 'ほげ' (length=6)
  '列名2' => string 'ほげ' (length=6)
  '列名3' => string 'ほげ' (length=6)
array (size=4)
  '列名1' => string 'ふが' (length=6)
  '列名2_1' => string 'ふが' (length=6)
  '列名2' => string 'ふが' (length=6)
  '列名3' => string 'ふが' (length=6)
array (size=4)
  '列名1' => string 'ぴよ' (length=6)
  '列名2_1' => string 'ぴよ' (length=6)
  '列名2' => string 'ぴよ' (length=6)
  '列名3' => string 'ぴよ' (length=6)

なぜこの方法で読み込めるようになるのか?

league/csvのソースのうち、列名がユニークかどうかをチェックしている箇所は この部分 です。

protected function computeHeader(array $header)
{
    if ([] === $header) {
        $header = $this->getHeader();
    }

    if ($header === array_unique(array_filter($header, 'is_string'))) {
        return $header;
    }

    throw new SyntaxError('The header record must be an empty or a flat array with unique string values.');
}

ユニークかどうかのチェックの対象となるヘッダー行の初期値は、 $this->getHeader() で取得していることが分かりますね。

なので、 getHeader() メソッドを上書きして、

public function getHeader(): array
{
    // 元の処理をそのまま実行
    parent::getHeader();

    // その上で、列名がユニークでないものには、列名の後ろに "_{列の位置}" という文字列を付加する
    foreach ($this->header as $key => $value) {
        if (array_count_values($this->header)[$value] > 1) {
            $this->header[$key] = $value . '_' . $key;
        }
    }

    return $this->header;
}

という具合に、読み込みの段階で強引に列名を読み替えるようにしてみたわけです。

この方法で一応読み込んで取り扱うことができるようになりますが、ユニークでなかった列名だけが改変された状態で扱うことになるので、その辺はいい感じに気にしながら処理を作り込む必要があるかもしれません。

(僕の場合は、ユニークでなかった列は特に使わない列だったので、この方法でまったく問題ありませんでした)

まとめ

  • league/csvは列名がユニークでないCSVを読み込もうとするとエラーになる
  • getHeader() メソッドを上書きして改変することで、強引に読み込めるようにすることは可能
  • 用法用量を守ってお使いください
GitHubで編集を提案

Discussion