🌟

Laravel 配列のグループ化とページ分け問題

2021/08/22に公開

Laravel Eloquentを使う時に、groupByメソッドもついているので、簡単に使えます。

$orders = DB::table('products')
                ->select('name', 'price', 'category')
                ->groupBy('category')
                ->get();

SQLでは次に相当:

SELECT name, price, category FROM products GROUP BY category;

ほとんどの場面で、クエリーを作る時にgroupByをチェインすることで問題は解決できます。

特別な場面には配列で処理

とあるプロジェクトの話ですが、上記の方法ではうまく対応できません。理由は多少複雑ですが、おおまかに言えば、1)二つ以上のテーブルのデータを合併する必要がある(コラム数が多いためselect+joinが非常に煩雑になる)、2)とある特定のコラムでグループ化するではなく、そのコラムのデータの一部でグループ化する必要がある(例えば9桁の番号だが、上位8桁でグループ化する)。

そういった場面を対処するために、一般化できるPHP配列での処理を試してみました。

まずはコレクションデータまたは配列を、特定のコラムでグループ化すること。こちらはコレクションのgroupBy処理の配列版とも言えます。

function groupItemsByColumn(Collection|array $items, String $column) : array
{
    $items = is_array($items) ? $items : $items->toArray();
    if (empty($items)) return [];
    
    return array_reduce($items, function (array $acc, array $el) use ($column) {
        $group = $el[$column];
        $acc[$group][] = $el;
        return $acc;
    }, []);
}

groupItemsByColumn関数で、次のような構造の変換が可能となります。

$languages = [
    ['id'=>'111', 'type' => 'interpreted', 'name'=>'PHP'],
    ['id'=>'112', 'type' => 'interpreted', 'name'=>'JavaScript'],
    ['id'=>'113', 'type' => 'interpreted', 'name'=>'Python'],
    ['id'=>'001', 'type' => 'compiled', 'name'=>'C'],
    ['id'=>'002', 'type' => 'compiled', 'name'=>'Go'],
    ['id'=>'003', 'type' => 'compiled', 'name'=>'Rust'],
    ['id'=>'221', 'type' => 'mixed', 'name'=>'Java'],
    ['id'=>'222', 'type' => 'mixed', 'name'=>'Kotlin'],
]

$grouped = groupItemsByColumn($lanugages, 'type');
$grouped = [
    'interpreted' => [
        ['id'=>'111', 'type' => 'interpreted', 'name'=>'PHP'],
        ['id'=>'112', 'type' => 'interpreted', 'name'=>'JavaScript'],
        ['id'=>'113', 'type' => 'interpreted', 'name'=>'Python'],
    ],
    'compiled' => [
        ['id'=>'001', 'type' => 'compiled', 'name'=>'C'],
        ['id'=>'002', 'type' => 'compiled', 'name'=>'Go'],
        ['id'=>'003', 'type' => 'compiled', 'name'=>'Rust'],
    ],
    'mixed' => [
        ['id'=>'221', 'type' => 'mixed', 'name'=>'Java'],
        ['id'=>'222', 'type' => 'mixed', 'name'=>'Kotlin'],
    ]
];

もし上記の例に、typeコラムがない、けどグループ化しないといけない時は、idの上位2桁でグループ化するのも可能だが、$group変数に少し条件判断を加える:

$group = $column == 'id' ? substr($el['id'], 0, -1)) : $el[$column];

もちろん、グループ名(キー)が00,11とかになってしまいますが、一応同じグループ分けが可能となります。

配列データをブレードビューに渡す時のページ分け問題

上記の処理で、グループ化された配列データを取得することができました。

これで終わりと思ったら、次にまた現実的な問題が出てきて、項目数が多いため、pagination/ページ分けが必要になってくると。

Laravelでは、Eloquentを使う時にpaginateメソッドをチェインすることで、簡単にpaginatorオブジェクトを作ることが可能:

$users = DB::table('users')->paginate(5);
$items = Item::where('stock', '>', 0)->paginate(); //デフォルトは15

しかし、前節の処理では、すでに配列に変換しているため、当然paginateメソッドは使えません。この問題を一般的に考えると、いわば配列データをいかにブレードビューに渡し、ページ分けすることです。さらにいうと、配列データのままではページ分けが若干難しくなるため、いかに配列データをpaginatorオブジェクトに変換するか、が肝になるかと。

ここでLengthAwarePaginatorを使うことで解決できます:

use Illuminate\Pagination\LengthAwarePaginator;

//...とあるコントローラで
$grouped = groupItemsByColumn($items, 'type');
$current_page = $request->current_page ?: 1;
$per_page = $request->per_page ?: 10;
$data = new LengthAwarePaginator(
            collect($grouped)->forPage($current_page, $per_page),
            count($grouped),
            $per_page,
            $current_page,
            ['path' => $request->url()]
        );
return view('Item.list', compact('data'));

ここでいくつかのポイントとして:

  • 配列データをcollect関数でコレクションタイプに変換
  • コレクションにforPageメソッドでページ情報を渡す
  • urlは現在リクエストのurlを渡す
  • 2番目のパラメーターは配列の合計項目数を渡すが、ここはグループ化後の数を渡している

もしいくつかのコントローラで同じことをするならば、traitとして抽象化することも考えられるでしょう。

<?php

namespace App\Http\Controllers\Traits;

use Illuminate\Pagination\LengthAwarePaginator;


trait HasPaginator
{
    protected function createPaginator(array $data, ?int $current_page, ?int $per_page, String $url)
    {
        $current_page = $current_page ?: 1;
        $per_page = $per_page ?: ITEM_PER_PAGE; // 定数を定義するなど
        return new LengthAwarePaginator(
            collect($data)->forPage($current_page, $per_page),
            count($data),
            $per_page,
            $current_page,
            ['path' => $url]
        );
    }
}

// とあるコントローラで
use HasPaginator;
// ...
$data = $this->createPaginator($grouped, $request->page, $request->per_page, $request->url());

これでブレードビューで、Eloquentを使う時と同じくページ分けを表示できます。URLのquery stringを保持したい場合はappendsを付けましょう。

<div class="d-flex justify-content-center mb-5">
    {{ $data->links() }}
    {{-- query stringをつける --}}
    {{ $data->appends(request()->except('page'))->links() }}
</div>

ちなみにEloquentを使う場合はwithQueryStringメソッドをpaginateメソッドにチェインすることで可能となります。

$items = Item::where('stock', '>', 0)->paginate(15)->withQueryString();

おそらく多くの場合はEloquentのメソッドで対処はできると思いますが、今回は若干特別な場面、配列データのグループ化とページ分けについてまとめました。

以上です!

Discussion