Laravel6からLaravel7へのバージョン更新で起こった問題を深~く追及していったら、私のプログラムのバグの発見とともにLaravelフレームワークの内部の変更の背景を知ることになりました。
groupByを使ったパジネーションの問題
Laravel6.xからLaravel7.xに更新して、動作テストを行っていたらこのようなこと起こりました。
まず、テストケースの作成です。ユーザーの一日の歩行数を記録するとして以下の構造を持つstepsのテーブルを作成します。
user_idは、usersのuser.idとして、date_loggedが対象の日付、そしてstepsが歩行数です。
mysql> describe steps; +-------------+----------------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------------+----------------------+------+-----+---------+----------------+ | id | bigint(20) unsigned | NO | PRI | NULL | auto_increment | | user_id | int(10) unsigned | NO | | NULL | | | date_logged | date | NO | | NULL | | | steps | smallint(5) unsigned | NO | | 0 | | | created_at | timestamp | YES | | NULL | | | updated_at | timestamp | YES | | NULL | | +-------------+----------------------+------+-----+---------+----------------+ 6 rows in set (0.00 sec)
factory()
を利用してDBテーブルにデータを入れます。こんなデータです。
mysql> select * from steps; +----+---------+-------------+-------+---------------------+---------------------+ | id | user_id | date_logged | steps | created_at | updated_at | +----+---------+-------------+-------+---------------------+---------------------+ | 1 | 4 | 2021-02-20 | 1119 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 2 | 3 | 2021-02-22 | 3955 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 3 | 4 | 2021-02-21 | 294 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 4 | 2 | 2021-02-22 | 1473 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 5 | 1 | 2021-02-22 | 2858 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 6 | 2 | 2021-02-21 | 1517 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 7 | 3 | 2021-02-21 | 2913 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 8 | 2 | 2021-02-21 | 3653 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 9 | 1 | 2021-02-24 | 309 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | | 10 | 1 | 2021-02-24 | 2985 | 2021-02-26 15:53:46 | 2021-02-26 15:53:46 | +----+---------+-------------+-------+---------------------+---------------------+ 10 rows in set (0.01 sec)
さて、Laravel6.xの環境でtinkerを実行して以下のクエリを実行します。日付でグループ化してそれぞれの日付の総歩数を表示したいわけです。しかも、画面で表示するのでパジネーションを使います。
>>> use App\Step; >>> $pager = Step::select('date_logged', DB::raw('sum(steps) as steps'), 'date_logged') ->groupBy('steps.date_logged') ->paginate(2) => Illuminate\Pagination\LengthAwarePaginator {#4152 +onEachSide: 3, } >>> $pager->toArray(); => [ "current_page" => 1, "data" => [ [ "date_logged" => "2021-02-20", "steps" => "1119", ], [ "date_logged" => "2021-02-21", "steps" => "8377", ], ], "first_page_url" => "http://localhost?page=1", "from" => 1, "last_page" => 2, "last_page_url" => "http://localhost?page=2", "next_page_url" => "http://localhost?page=2", "path" => "http://localhost", "per_page" => 2, "prev_page_url" => null, "to" => 2, "total" => 4, ]
実行は問題ないですね。全部(total)で合計4つのレコードがあり、1ページあたり2レコード表示で最初の2つレコードを抽出(data)です。
さて、今度は同じクエリをLaravel 7.xで実行すると、
>>> use App\Step; >>> $pager = Step::select('date_logged', DB::raw('sum(steps) as steps'), 'date_logged') ->groupBy('steps.date_logged') ->paginate(2) Illuminate\Database\QueryException with message 'SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'date_logged' (SQL: select count(*) as aggregate from (select `date_logged`, sum(steps) as steps, `date_logged` from `steps` group by `steps`.`date_logged`) as `aggregate_table`)'
とクエリがSQLの実行エラーになっていしまいます。
エラーは初歩的なミスで、クエリが返す項目に重複の項目があるよ、という指摘です。そうですね、select()
でdate_loggedが重複しています。
しかし、Laravel 6.xでは問題ありませんでしたね。また、以下のpaginate()
をget()
に置き換えたクエリの実行はLaravel 7.xでも問題はありません。
>>> Step::select('date_logged', DB::raw('sum(steps) as steps'), 'date_logged') ->groupBy('steps.date_logged') ->get(); => Illuminate\Database\Eloquent\Collection {#3658 all: [ App\Step {#4220 date_logged: "2021-02-20", steps: "1119", }, App\Step {#4219 date_logged: "2021-02-21", steps: "8377", }, App\Step {#4065 date_logged: "2021-02-22", steps: "8286", }, App\Step {#3337 date_logged: "2021-02-24", steps: "3294", }, ], }
エラーの原因は何でしょう?paginate()
の関数のコードがLaravel7.xに変わった?
Laravel 6.xに戻って、実行されたクエリを見てみると、
>>> $pager = Step::select('date_logged', DB::raw('sum(steps) as steps'), 'date_logged') ->groupBy('steps.date_logged') ->paginate(2) => Illuminate\Pagination\LengthAwarePaginator {#3241 +onEachSide: 3, } >>> sql(); => [ [ "query" => "select count(*) as aggregate from `steps` group by `steps`.`date_logged`", "bindings" => [], "time" => 1.37, ], [ "query" => "select `date_logged`, sum(steps) as steps, `date_logged` from `steps` group by `steps`.`date_logged` limit 2 offset 0", "bindings" => [], "time" => 0.82, ], ] ]
Laravel 7.xで重複を修正して再度実行すると、
>>> use App\Step; >>> $pager = Step::select('date_logged', DB::raw('sum(steps) as steps')) ->groupBy('steps.date_logged') ->paginate(2) => Illuminate\Pagination\LengthAwarePaginator {#4274 +onEachSide: 3, } >>> sql() => [ [ "query" => "select count(*) as aggregate from (select `date_logged`, sum(steps) as steps from `steps` group by `steps`.`date_logged`) as `aggregate_table`", "bindings" => [], "time" => 164.63, ], [ "query" => "select `date_logged`, sum(steps) as steps from `steps` group by `steps`.`date_logged` limit 2 offset 0", "bindings" => [], "time" => 0.42, ], ]
どちらを見てもわかるように、パジネーションを作成するには、指定したレコードを取得するクエリを実行するだけなく、対象となる総レコード数のクエリも実行されます。もちろんそうでないと、全部でのページ数がわかりません。
しかし、そのレコード数計算のためのクエリを比較してみると、Laravel 7.xでは対象のクエリをサブクエリとしてSQL文のFromに与えています。そして、そこの部分で項目の重複を許さないためにエラーとなった次第です。なるほど!
また、Laravel 7.xが一発でレコード数の結果を返しますが、Laravel 6.xのレコード数計算のクエリは結果が複数のレコードで返されますので、さらにそれ自体をカウントしなければいけません。多分にプログラムでカウントしていたのかもしれませんが、効率は良くないですね。
調べてみると
まず、Laravel 6.xのマニュアルでは、この部分
Currently, pagination operations that use a groupBy statement cannot be executed efficiently by Laravel. If you need to use a groupBy with a paginated result set, it is recommended that you query the database and create a paginator manually.
現在groupBy文を使用したパジネーションの操作は、Laravelで効率よく実行できません。groupByを使用したパジネーションを使用する必要がある場合はデータベースクエリを実行し、その結果を元にパジネーターを自前で作成してください)
とあります。
この記述はLaravel 7.xのマニュアルにもありますが、最新の7xのコードでは修正されているはずです(次を読むとわかります)。Laravel 8.xではこの記述はありません。
もう少し調べてみると、github.comのissuesにまさにそのマージありました。
[7.x] Run pagination count as subquery for group by and havings
Paginating queries with groupBy or having statements is a long-standing issue in Laravel going back to the very beginning of the framework with literal dozens of raised issues:
#1892, #2761, #3105, #4306, #6985, #7372, #9567, #10632, #14123, #16320, #17406, #22883, #28931
This solution was suggested @acasar years ago but I wrote it off at the time – but honestly I think it’s a lot better than what we have now so I’m bringing it up again for consideration.
groupByあるいはhavingを使用したパジネーションは、フレームワークの開発当初からたくさん問題として取り上げられたLaravelの長年の課題でした。
今回の@acasarが何年も前に提示した解決方法は、当時私が取り上げなかったものですが、正直言って現在使用されているものより良いので、ここで再度取り入れることにします。
なるほど、Laravelの作者でもこういうことがあるのですね。というかたくさんの人が改良のアイデア(あるいは不平)をいつも与えて続けているのは好まれている証拠です。
メルマガ購読の申し込みはこちらから。