【PHP】CSVの特定列を取得する

## 目的
CSVを読み込み、カラム名で指定した値を取得したい。

## 環境
Windows 10 Home 64bit
XAMPP Version 7.3.11
(PHP 7.3.11 (VC15 X86 64bit thread safe) + PEAR)

## 対応
CSV用のクラスを作成して対応しました。
説明はコメントとして記載しているので、割愛します。
以下、ソースコードです。

#### CSVハンドリングクラス

**2020-0501 追記**
改行、ダブルクォートが含まれるデータへの対応を追加しました

```php
/*
 * CSV分割クラス
 */
class csv_handle
{
    // 文字エンコード用定数
    private const ENCODING = 'UTF-8';
    /**
     * ダブルクォートが閉じられているか判断する用の定数
     * ※エンクロージャ2つで1フィールドなので、定数を用いてフィールドの途中で改行が入っても1つのデータとしてまとめるようにする
     */
    private const ENCL_END = 2;

    // ヘッダ
    private $csv_header = array();
    private $data = array();

    /**
     * 引数の文字列をデリミタで分割した配列を取得する
     *
     * @param string $line 分割対象の文字列
     * @param string $delim デリミタ(区切り文字)
     * @param string $encl エンクロージャ(データ領域を囲う文字)
     * @return array
     **/
    private function get_separated_array(string $line, string $delim, string $encl)
    {
        // 正規表現を使うので、エスケープをしておく
        $ecl = preg_quote($encl);
        $dlm = preg_quote($delim);

        /**
         * CSV分解用に正規表現を設定
         * $delim = ',' $encl = '"' の場合、以下の正規表現になる
         * ""が2回続く場合はは、フィールドの切れ目としてみなす必要があるので、以下の正規表現とする
         * ("[^"]*(""[^"]*)*"|[^,]*),
         * -------------------------- c
         *  ----------------- -----
         *  a                 b
         * aの説明
         *  a. "[^"]*    -> " かつ、次が " でない任意の文字列
         *  b. (?:""[^"]*) -> "" かつ、次が " でない任意の文字列(" " 形式で囲まれたフィールド内のエスケープされた " を取得)
         *                    (?:xxxx)という表現で、グループとしてキャプチャされなくなる
         *  c. *"        -> 任意の文字で、" が見つかるまでの文字列
         *   -> "" となっていないかつ、
         * bの説明
         *  a. [^,]*     -> , でない任意の文字列
         * cの説明
         *  ("[^"]*(""[^"]*)*"|[^,]*), -> ①か②に合致かつ、 , が見つかるまでの文字列
         * 正規表現図 -> https://regexper.com/#%28%22%5B%5E%22%5D*%28%22%22%5B%5E%22%5D*%29*%22%7C%5B%5E%2C%5D*%29%2C
         */
        $a_a = '[^'. $ecl .']*';
        $a_b = '(?:' . $ecl . $ecl . '[^'. $ecl .']*)';
        $a_c = '*'. $ecl;
        $b_a = '[^' . $dlm. ']*';
        $pattern = '/(' . $ecl . $a_a . $a_b . $a_c . '|' . $b_a . ')' . $dlm . '/';

        // 正規表現で全文字をマッチさせるため、文字列の終端にデリミタを付与
        $line_add_delim = $line . $delim;

        // 正規表現で分解し、配列として取得
        $match_result = preg_match_all($pattern, $line_add_delim, $separated);
        $result = array();

        /**
         * 取得した配列は[0]がパターン全体のデータ、
         * [1]が最初の()でマッチしたデータ、[2]が2番目の()でマッチしたデータ、、、、となっている
         * 今回は1つしか()がないので、[1]を検知結果として取得する
         * また、[1]には最後の$delimが含まれていないため、$delimをトリムする処理も不要
         */
        foreach ($separated[1] as $value) {
            // ダブルクォートが入っている場合は削除
            // $replace = str_replace('"', '', $value);
            $replace = preg_replace('/^' . $ecl . '(.*)' . $ecl . '$/s', '$1', $value);
            $replace = str_replace($ecl . $ecl , $ecl, $replace);
            $result[] = $replace;
        }
        return $result;
    }

    /**
     * CSVファイルを読み込み、クラスにデータを保持する
     *
     * @param string $file_path 読み込みファイルパス
     * @param string $delim デリミタ(区切り文字)
     * @param string $encl エンクロージャ(データ領域を囲う文字)
     * @param string $encode エンコード情報
     **/
    public function read_csv(string $file_path, string $delim, string $encl, string $encode = self::ENCODING){
        // 最後の改行コードは無視する
        $file = file($file_path, FILE_IGNORE_NEW_LINES);
        // 内部ではUTF-8で情報を持つ
        $file_enc_conv = mb_convert_encoding($file, self::ENCODING, $encode);

        $target_array = array();
        $target_line = "";
        $delim_count = 0;
        foreach($file as $line){
            // 処理対象行に追加（改行コードを含む場合は、PHPのシステム環境の改行コードを明示的に付与する）
            $target_line .= 0 === $delim_count ? $line : PHP_EOL . $line;
            // エンクロージャの数を取得
            $delim_count += substr_count($line, $encl);

            // エンクロージャは2つでセットなので、2で割り切れない場合は1つのレコード情報とする
            if (0 === $delim_count % self::ENCL_END){
                $target_array[] = $target_line;
                $target_line = "";
                $delim_count = 0;
            }
        }

        // ヘッダーの登録
        $this->csv_header = $this->get_separated_array(array_shift($target_array), $delim, $encl);

        // 実データの登録
        foreach($target_array as $value){
            $this->data[] = $this->get_separated_array($value, $delim, $encl);;
        }
    }


    /**
     * 指定したカラムのデータを取得する
     *
     * @param string $column_name カラム名(可変長引数)
     **/
    public function get_column_data(string ...$column_name){
        // 取得カラムのインデックスを取得
        $column_indexes = array();
        foreach($column_name as $column){
            $tmp = array_search($column, $this->csv_header);
            // 存在しないカラムの場合は、エラーメッセージを出力
            if(false === $tmp){
                echo printf("no column : %s\n", $column);
                continue;
            }
            $column_indexes[] = $tmp;
        }

        // 戻り値の作成
        $result = array();
        foreach($this->data as $index => $value){
            foreach($column_indexes as $c_index){
                $result[$index][$this->csv_header[$c_index]] = $value[$c_index];
            }
        }

        return $result;
    }
}

```

#### テストデータ（split_test.csv）

**2020-0430 追記**
※ CSVのフィールドデータ内に改行やダブルクォートが含まれないデータを前提としております

**2020-0501 追記**
テストデータに、改行、ダブルクォートが含まれるデータを利用するように変更しました

```
c1,c2,c3,c4,c5
"テスト","日
,
a""
本",語,1,2
"av","a,c",c,11,88
"v","b,d",c,991,38
"g","g,k",c,21,88
"aaa","b,l",c,5,88
```

#### 使い方

```php
// 読み込みファイルパス
$path = 'D:/tmp/split_test.csv';
// クラス初期化
$line_sep = new csv_handle();
// CSV読み込み
$line_sep->read_csv($path, ',', '"');
// c1, c2 の列のみ取得
$result = $line_sep->get_column_data('c1', 'c2');
// 結果を表示
print_r($result);
```

#### 出力結果

```
Array
Array
(
    [0] => Array
        (
            [c1] => テスト
            [c2] => 日
,
a"
本
        )

    [1] => Array
        (
            [c1] => av
            [c2] => a,c
        )

    [2] => Array
        (
            [c1] => v
            [c2] => b,d
        )

    [3] => Array
        (
            [c1] => g
            [c2] => g,k
        )

    [4] => Array
        (
            [c1] => aaa
            [c2] => b,l
        )

)
```

### 参考サイト
以下のリンクを参照させていただきました。

#### PHP分割処理
改行、`""`の対応については、以下のページを参照し処理を流用させていただきました。

本来であれば、`fgetcsv_reg`を作成されたyossyさんのページもリンクするべきですが、
該当URLのページが存在しなかったため、参照させていただいたページのみ記載しております。

* [PHP5のfgetcsvに関して](https://gyagya1111.blogspot.com/2010/12/php5fgetcsv.html)
    * yossyさんの`fgetcsv_reg`を改良されたソースを記載いただいてます

#### CSV

* [CSVファイルフォーマットの解説](https://codezine.jp/article/detail/2364)

#### 正規表現

* [正規表現あれこれ](https://qiita.com/ikmiyabi/items/12d1127056cdf4f0eea5)
* [PHP正規表現 複数の要素を一回の実行で抽出する](https://unskilled.site/php%e6%ad%a3%e8%a6%8f%e8%a1%a8%e7%8f%be-%e8%a4%87%e6%95%b0%e3%81%ae%e8%a6%81%e7%b4%a0%e3%82%92%e4%b8%80%e5%9b%9e%e3%81%ae%e5%ae%9f%e8%a1%8c%e3%81%a7%e6%8a%bd%e5%87%ba%e3%81%99%e3%82%8b/)
* [正規表現サンプル集](https://www.megasoft.co.jp/mifes/seiki/)
* [正規表現でたまに見る「?:」とは](http://otukutun.hatenablog.com/entry/2015/08/02/004458)

##### 正規表現確認

* [Rubular](https://rubular.com/)
    * サンプルデータを読み込ませて正規表現のマッチを確認できます
* [Regexper](https://regexper.com/)
    * 正規表現を視覚化して、漏れがないかを確認できます


### 処理作成で時間のかかったところ
`"`で挟まれているデータを1つとしてみなす部分が個人的には難しいポイントでした。
今回の処理だけでなく、データのクリーニングに利用できる技術だと思いますので、理解を深められるよう精進します。

### 終わりに
自分なりにクラスを作って処理を作りましたが、まだまだ甘い部分も多いと思います。

クラスを作った後に[fget_csv](https://www.php.net/manual/ja/function.fgetcsv.php)という関数の存在を知りました。
最初からこれを利用していれば、クラスを作る必要もなかったですね……。

もっとこうした方が良い、このやり方おかしいんじゃない？等、ツッコミをいただけると嬉しいです！