PHPの連想配列で複数カラムを指定してGROUP BYしたい。

# こんな配列を`Group By`したい。

```php:GroupByArray.php
/** @var array 温泉地テーブル. */
private const TABLE = [
    ['kuni' => '日本', 'todofuken' => '群馬県', 'shi' => '吾妻郡', 'onsen' => '草津'],
    ['kuni' => '日本', 'todofuken' => '群馬県', 'shi' => '渋川市', 'onsen' => '伊香保'],
    ['kuni' => '日本', 'todofuken' => '神奈川県', 'shi' => '足柄下郡', 'onsen' => '湯河原'],
    ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '観海寺'],
    ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '浜脇'],
    ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '堀田'],
    ['kuni' => 'ドイツ', 'todofuken' => 'ヴュルテンベルク州', 'shi' => 'バーデン＝バーデン', 'onsen' => 'フリードリヒス'],
    ['kuni' => 'ドイツ', 'todofuken' => 'ヴュルテンベルク州', 'shi' => 'バーデン＝バーデン', 'onsen' => 'カラカラ'],
    ['kuni' => 'ドイツ', 'todofuken' => 'ヘッセン州', 'shi' => 'ヴィースバーデン', 'onsen' => 'アウカムタル'],
    ['kuni' => 'ドイツ', 'todofuken' => 'ヘッセン州', 'shi' => 'ヴィースバーデン', 'onsen' => 'カイザーフリードリヒ'],
];
```

# `foreach`で`group by`
[キーでグループ化する - Qiita](https://qiita.com/suin/items/c524cf84368332db426b) を参考にさせていただきました。

```php:GroupByArray.php
// 省略
/**
 * 2つの項目でGROUP BYする.
 * @param  string $key1 1番目のグループ化する項目の名前.
 * @param  string $key2 2番目のグループ化する項目の名前.
 */
public function groupByTwoKey(string $key1, string $key2): void
{
    $grouped = [];
    foreach (self::TABLE as $row) {
        $key = $row[$key1].$row[$key2];
        $grouped[$key][] = $row;
    }
    return $grouped;
}
// 省略
$groupBy = new GroupByArray();
var_dump($groupBy->groupByTwoKey('kuni','todofuken'));
```
```bash:キーにしたカラムが残ってちょっとうざい
$ php GroupByArray.php 
array(5) {
  ["日本群馬県"]=>
  array(2) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(9) "群馬県"
      ["shi"]=>
      string(9) "吾妻郡"
      ["onsen"]=>
      string(6) "草津"
    }
    [1]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(9) "群馬県"
      ["shi"]=>
      string(9) "渋川市"
      ["onsen"]=>
      string(9) "伊香保"
    }
  }
  ["日本神奈川県"]=>
  array(1) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(12) "神奈川県"
      ["shi"]=>
      string(12) "足柄下郡"
      ["onsen"]=>
      string(9) "湯河原"
    }
  }
  ["日本大分県"]=>
  array(3) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(9) "大分県"
      ["shi"]=>
      string(9) "別府市"
      ["onsen"]=>
      string(9) "観海寺"
    }
    [1]=>
    array(4) {
      ["kuni"]=>
# 省略
```
## `group by`のキーにしたカラムはいらない場合
```php:GroupByArray->groupByTwoKey
// 省略
    foreach (self::TABLE as $row) {
        /** @var array $notKeys キーにする要素を削除した配列. */
        $notKeys = array_filter($row, function($key) use ($key1, $key2) {
            return $key !== $key1 && $key !== $key2;
        }, ARRAY_FILTER_USE_KEY);

        $key = $row[$key1].$row[$key2];
        $grouped[$key][] = $notKeys;
// 省略
```
```bash
$ php GroupByArray.php 
array(5) {
  ["日本群馬県"]=>
  array(2) {
    [0]=>
    array(2) {
      ["shi"]=>
      string(9) "吾妻郡"
      ["onsen"]=>
      string(6) "草津"
    }
    [1]=>
    array(2) {
      ["shi"]=>
      string(9) "渋川市"
      ["onsen"]=>
      string(9) "伊香保"
    }
  }
  ["日本神奈川県"]=>
  array(1) {
    [0]=>
    array(2) {
      ["shi"]=>
      string(12) "足柄下郡"
      ["onsen"]=>
      string(9) "湯河原"
    }
  }
  ["日本大分県"]=>
  array(3) {
    [0]=>
    array(2) {
      ["shi"]=>
      string(9) "別府市"
      ["onsen"]=>
      string(9) "観海寺"
    }
    [1]=>
    array(2) {
      ["shi"]=>
      string(9) "別府市"
      ["onsen"]=>
# 省略
```
## `group by`のキーにしたカラム毎に階層を作りたい場合
```php:GroupByArray->groupByTwoKey
// 省略
        /** @var array $notKeys キーにする要素を削除した配列. */
        $notKeys = array_filter($row, function($key) use ($key1, $key2) {
            return $key !== $key1 && $key !== $key2;
        }, ARRAY_FILTER_USE_KEY);

        $grouped[$row[$key1]][$row[$key2]][] = $notKeys;
// 省略
```
```bash
$ php GroupByArray.php 
array(2) {
  ["日本"]=>
  array(3) {
    ["群馬県"]=>
    array(2) {
      [0]=>
      array(2) {
        ["shi"]=>
        string(9) "吾妻郡"
        ["onsen"]=>
        string(6) "草津"
      }
      [1]=>
      array(2) {
        ["shi"]=>
        string(9) "渋川市"
        ["onsen"]=>
        string(9) "伊香保"
      }
    }
    ["神奈川県"]=>
    array(1) {
      [0]=>
      array(2) {
        ["shi"]=>
        string(12) "足柄下郡"
        ["onsen"]=>
        string(9) "湯河原"
# 省略
```

# `array_reduce`で`group by`
[集計するとき、array_reduceと無名関数が便利だった！ - maeharinの日記](http://maeharin.hatenablog.com/entry/20121108/p1)を参考にさせていただきました。
使い方は、ドキュメントを見ました([array_reduce — コールバック関数を用いて配列を普通の値に変更することにより、配列を再帰的に減らす](http://php.net/manual/ja/function.array-reduce.php))

```php:GroupByArray.php
/**
 * 3つの項目でGROUP BYする.
 * @param  string $key1 1番目のグループ化する項目の名前.
 * @param  string $key2 2番目のグループ化する項目の名前.
 * @param  string $key3 3番目のグループ化する項目の名前.
 */
public function groupByTreeKey(string $key1, string $key2, string $key3): void
{
    $grouped = array_reduce(self::TABLE, function($grouped, $row) use ($key1, $key2, $key3) {
        $key = $row[$key1].$row[$key2].$row[$key3];
        $grouped[$key][] = $row;
        return $grouped;
    });
    return $grouped;
}
// 省略
$groupBy = new GroupByArray();
var_dump($groupBy->groupByTreeKey('kuni','todofuken', 'shi'));
```
```bash:とてもうざい
$ php GroupByArray.php 
array(6) {
  ["日本群馬県吾妻郡"]=>
  array(1) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(9) "群馬県"
      ["shi"]=>
      string(9) "吾妻郡"
      ["onsen"]=>
      string(6) "草津"
    }
  }
  ["日本群馬県渋川市"]=>
  array(1) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(9) "群馬県"
      ["shi"]=>
      string(9) "渋川市"
      ["onsen"]=>
      string(9) "伊香保"
    }
  }
  ["日本神奈川県足柄下郡"]=>
  array(1) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
      ["todofuken"]=>
      string(12) "神奈川県"
      ["shi"]=>
      string(12) "足柄下郡"
      ["onsen"]=>
      string(9) "湯河原"
    }
  }
  ["日本大分県別府市"]=>
  array(3) {
    [0]=>
    array(4) {
      ["kuni"]=>
      string(6) "日本"
# 省略
```
## `group by`のキーにしないカラムを連結したい場合
```php:GroupByArray->groupByTreeKey
/** @var string カンマ. */
private const COMMA = ',';
// 省略
    $grouped = array_reduce(self::TABLE, function($grouped, $row) use ($key1, $key2, $key3) {
        $notKeys = array_filter($row, function($key) use($key1, $key2, $key3) {
            return $key !== $key1 && $key !== $key2 && $key !== $key3;
        }, ARRAY_FILTER_USE_KEY);
        $notKey = implode(self::COMMA, $notKeys);

        $key = $row[$key1].$row[$key2].$row[$key3];

        if (array_key_exists($key, $grouped)) {
            $grouped[$key] .= self::COMMA.$notKey;
        } else {
            $grouped[$key] = $notKey;
        }

        return $grouped;
    }, array());
// 省略
```
```bash
$ php GroupByArray.php 
array(6) {
  ["日本群馬県吾妻郡"]=>
  string(6) "草津"
  ["日本群馬県渋川市"]=>
  string(9) "伊香保"
  ["日本神奈川県足柄下郡"]=>
  string(9) "湯河原"
  ["日本大分県別府市"]=>
  string(23) "観海寺,浜脇,堀田"
  ["ドイツヴュルテンベルク州バーデン＝バーデン"]=>
  string(34) "フリードリヒス,カラカラ"
  ["ドイツヘッセン州ヴィースバーデン"]=>
  string(49) "アウカムタル,カイザーフリードリヒ"
}
```

# `group by`してフォーマットに埋め込む
[文字列リテラルに式展開 - Qiita](https://qiita.com/tadsan/items/e4796449c736cfb5c9bd)で思いつきました。
素晴らしく汎用性のないものとなりました。

```php:GroupByArray
/**
 * group byした結果をフォーマットに埋め込む.
 * @return string フォーマットした文字列.
 */
public function groupByAndFormat(): string
{
    $key1 = 'kuni';
    $key2 = 'todofuken';
    $key3 = 'other';
    $format = "{$key1}の{$key2}の温泉には、{$key3}があります。";

    /** @var array $groups group byした結果. */
    $groups = self::groupByTwoKey($key1, $key2);

    /** @var array $formated フォーマットした文字を格納する配列. */
    $formated = array();
    /** @var string $kuni kuniカラムの値. */
    foreach ($groups as $kuni => $todofukens) {
        /** @var string $todofuken todofukenカラムの値. */
        foreach ($todofukens as $todofuken => $others) {
            /** @var string $other group byのキーにしなかった要素を「と」でつないだ文字列. */
            $other = '';
            foreach ($others as $otherParts) {
                /** @var string $otherPart group byのキーにしなかった要素をくっつけた文字列. */
                $otherPart = implode($otherParts);
                if (strlen($other) !== 0) {
                    $other .= 'と';
                }
                $other .= $otherPart;
            }
            /** @var array 置換する文字のセット. */
            $replacePairs = [$key1 => $kuni, $key2 => $todofuken, $key3 => $other];
            $formated[] = strtr($format, $replacePairs);
        }
    }

    // 改行でつなげて1つの文字列にする.
    return implode(PHP_EOL, $formated);
}
// 省略
$groupBy = new GroupByArray();
var_dump($groupBy->groupByAndFormat());
```
```bash
$ php GroupByArray.php 
string(607) "日本の群馬県の温泉には、吾妻郡草津と渋川市伊香保があります。
日本の神奈川県の温泉には、足柄下郡湯河原があります。
日本の大分県の温泉には、別府市観海寺と別府市浜脇と別府市堀田があります。
ドイツのヴュルテンベルク州の温泉には、バーデン＝バーデンフリードリヒスとバーデン＝バーデンカラカラがあります。
ドイツのヘッセン州の温泉には、ヴィースバーデンアウカムタルとヴィースバーデンカイザーフリードリヒがあります。"
```

# 全貌
```php:GroupByArra.php
<?php

class GroupByArray
{
    /** @var array 温泉地テーブル. */
    private const TABLE = [
        ['kuni' => '日本', 'todofuken' => '群馬県', 'shi' => '吾妻郡', 'onsen' => '草津'],
        ['kuni' => '日本', 'todofuken' => '群馬県', 'shi' => '渋川市', 'onsen' => '伊香保'],
        ['kuni' => '日本', 'todofuken' => '神奈川県', 'shi' => '足柄下郡', 'onsen' => '湯河原'],
        ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '観海寺'],
        ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '浜脇'],
        ['kuni' => '日本', 'todofuken' => '大分県', 'shi' => '別府市', 'onsen' => '堀田'],
        ['kuni' => 'ドイツ', 'todofuken' => 'ヴュルテンベルク州', 'shi' => 'バーデン＝バーデン', 'onsen' => 'フリードリヒス'],
        ['kuni' => 'ドイツ', 'todofuken' => 'ヴュルテンベルク州', 'shi' => 'バーデン＝バーデン', 'onsen' => 'カラカラ'],
        ['kuni' => 'ドイツ', 'todofuken' => 'ヘッセン州', 'shi' => 'ヴィースバーデン', 'onsen' => 'アウカムタル'],
        ['kuni' => 'ドイツ', 'todofuken' => 'ヘッセン州', 'shi' => 'ヴィースバーデン', 'onsen' => 'カイザーフリードリヒ'],
    ];

    /** @var string カンマ. */
    private const COMMA = ',';

    /**
     * 2つの項目でGROUP BYする.
     * @param  string $key1 1番目のグループ化する項目の名前.
     * @param  string $key2 2番目のグループ化する項目の名前.
     */
    public function groupByTwoKey(string $key1, string $key2): array
    {
        $grouped = [];
        foreach (self::TABLE as $row) {
            /** @var array $notKeys キーにする要素を削除した配列. */
            $notKeys = array_filter($row, function($key) use ($key1, $key2) {
                return $key !== $key1 && $key !== $key2;
            }, ARRAY_FILTER_USE_KEY);

            $grouped[$row[$key1]][$row[$key2]][] = $notKeys;
        }
        return $grouped;
    }

    /**
     * 3つの項目でGROUP BYする.
     * @param  string $key1 1番目のグループ化する項目の名前.
     * @param  string $key2 2番目のグループ化する項目の名前.
     * @param  string $key3 3番目のグループ化する項目の名前.
     */
    public function groupByTreeKey(string $key1, string $key2, string $key3): array
    {
        $grouped = array_reduce(self::TABLE, function($grouped, $row) use ($key1, $key2, $key3) {
            $notKeys = array_filter($row, function($key) use($key1, $key2, $key3) {
                return $key !== $key1 && $key !== $key2 && $key !== $key3;
            }, ARRAY_FILTER_USE_KEY);
            $notKey = implode(self::COMMA, $notKeys);

            $key = $row[$key1].$row[$key2].$row[$key3];

            if (array_key_exists($key, $grouped)) {
                $grouped[$key] .= self::COMMA.$notKey;
            } else {
                $grouped[$key] = $notKey;
            }

            return $grouped;
        }, array());
        return $grouped;
    }

    /**
     * group byした結果をフォーマットに埋め込む.
     * @return string フォーマットした文字列.
     */
    public function groupByAndFormat(): string
    {
        $key1 = 'kuni';
        $key2 = 'todofuken';
        $key3 = 'other';
        $format = "{$key1}の{$key2}の温泉には、{$key3}があります。";

        /** @var array $groups group byした結果. */
        $groups = self::groupByTwoKey($key1, $key2);

        /** @var array $formated フォーマットした文字を格納する配列. */
        $formated = array();
        /** @var string $kuni kuniカラムの値. */
        foreach ($groups as $kuni => $todofukens) {
            /** @var string $todofuken todofukenカラムの値. */
            foreach ($todofukens as $todofuken => $others) {
                /** @var string $other group byのキーにしなかった要素を「と」でつないだ文字列. */
                $other = '';
                foreach ($others as $otherParts) {
                    /** @var string $otherPart group byのキーにしなかった要素をくっつけた文字列. */
                    $otherPart = implode($otherParts);
                    if (strlen($other) !== 0) {
                        $other .= 'と';
                    }
                    $other .= $otherPart;
                }
                /** @var array 置換する文字のセット. */
                $replacePairs = [$key1 => $kuni, $key2 => $todofuken, $key3 => $other];
                $formated[] = strtr($format, $replacePairs);
            }
        }

        // 改行でつなげてⅠつの文字列にする.
        return implode(PHP_EOL, $formated);
    }
}

$groupBy = new GroupByArray();
var_dump($groupBy->groupByTwoKey('kuni','todofuken'));
var_dump($groupBy->groupByTreeKey('kuni','todofuken', 'shi'));
var_dump($groupBy->groupByAndFormat());
```