Laravel + MySQLでグループごとにランク付け/指定順位のデータだけ取得する方法(Window関数の代替)
「Laravel + MySQLでグループごとにランク付け(順位付け)して指定順位のデータだけ取得する方法」を紹介します。クエリの発行は1回だけです。
いろいろ迷った結果 2(+1)つの方法 を生み出したので、すべて紹介したいと思います!
(供養ってやつです。
こういったパターンに対応できます。
- MySQLのWindow関数を使いたいけど、バージョン等の理由により使えない
- SQL発行は1回で、順位付けしたデータの1位だけ取得したい
- その他、行番号をつけたい、Eloquentでサブクエリを使いたい、などの参考コードとして
この記事を書くにあたって使用したコードをGithubに公開しておきました。
色々試してみたい方は使ってみてください。記事の最後の関連リンクに置いています。
やりたいこと
例として、「目的地別に、最後のフライトを表示する」 ということをやります。
飛行機が到着すると、目的地や到着時間を記録したフライトが記録されるイメージです。
実際にテーブルとデータを見てみましょう。
使うのは以下の2つです。
使用するテーブルとデータ
flights
目的地ID、便名、到着時間を管理する。
+----------------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+----------------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| destination_id | int unsigned | NO | | NULL | |
| name | varchar(255) | NO | | NULL | |
| arrived_at | datetime | NO | | NULL | |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
+----------------+-----------------+------+-----+---------+----------------+
+----+----------------+-------+---------------------+
| id | destination_id | name | arrived_at |
+----+----------------+-------+---------------------+
| 1 | 1 | ZN001 | 2021-02-01 08:00:00 |
| 2 | 1 | QT001 | 2021-02-01 10:00:00 |
| 3 | 1 | ZN002 | 2021-02-01 12:00:00 |
| 4 | 2 | ZN003 | 2021-02-01 14:00:00 |
| 5 | 2 | QT002 | 2021-02-01 16:00:00 |
+----+----------------+-------+---------------------+
destinations
目的地名を管理する。
+------------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
+------------+-----------------+------+-----+---------+----------------+
+----+-------------+
| id | name |
+----+-------------+
| 1 | バーチャル東京 |
| 2 | バーチャル大阪 |
+----+-------------+
方法1 上級サブクエリを使う
「上級サブクエリ」というものを使います。
【参照】上級サブクエリ
orderByDescで降順に並べ替えたものの中から、limitで最初の1件だけ取得する方法です。
return Destination::addSelect([
'last_flight' =>
Flight::select('name')
->whereColumn('destination_id', 'destinations.id')
->orderByDesc('arrived_at')
->limit(1)
])->get();
addSelectのカラムの一つにSQLをまるごと書いたようなクエリですね。
結果はこのようになります。
データと見比べてみると、確かに目的地別の最後のフライト名を取得できています。
発行されたSQLを見ると、項目が1つしか取得できない理由に納得。
↓↓↓
select
`destinations`.*
, (select
`name`
from `flights`
where
`destination_id` = `destinations`.`id`
order by
`arrived_at` desc limit 1
) as `last_flight`
from `destinations`
selectの中にサブクエリ(selectの中のselect)で書かれていたんですね
(Laravelのドキュメントに「サブクエリ」と書かれているので当然っちゃ当然ですけど!)
サブクエリは単一行、単一カラムで返すのが基本なので、取得できるのは1項目だけなんだな、と理解しました。
【参照】サブクエリー構文
方法2 ユーザー定義変数を使う
私が方法1があることを知らずに実装してしまった方法がこちらです。
MySQLの「ユーザー定義変数」というものを使います。
【参照】ユーザー定義変数
ちょっと複雑ですが、こちらは1位以外にも取得できる方法です。
// ユーザー定義変数をセット
DB::statement('SET @row_num = 0;');
// destination_idごとに順位付け
$flights = Flight::query()
->select(
'flights.*',
DB::raw('if(@_last_id != flights.destination_id
, @row_num := 1
, @row_num := @row_num + 1) as row_num'),
DB::raw('@_last_id := flights.destination_id as last_id')
)
->orderBy('flights.destination_id')
->orderByDesc('flights.arrived_at');
return Destination::query()
->select([
'destinations.*',
'flights.name as flights_name'
])
->joinSub($flights, 'flights', function ($join) {
$join->on('destinations.id', '=', 'flights.destination_id')
->where('flights.row_num', 1);
})
->get();
かなり遠回しっぽいやり方ですが、こっちの方が柔軟性はあります(DBがMySQLじゃないとけない制限はあります)。
結構複雑になってしまったので、解説します。
クエリ1を解説
// ユーザー定義変数を定義
DB::statement('SET @row_num = 0;'); ←①
// destination_idごとに順位付け
$flights = Flight::query()
->select(
'flights.*',
DB::raw('if(@_last_id != flights.destination_id
, @row_num := 1
, @row_num := @row_num + 1) as row_num'), ←③
DB::raw('@_last_id := flights.destination_id as last_id') ←④
)
->orderBy('flights.destination_id') ←②
->orderByDesc('flights.arrived_at');
①まずはユーザー定義変数 @row_num を定義して初期化します。
②Flightsを、destination_idの昇順・arrived_atの降順に並べ替えます。
③selectでdestination_idごとに順位 @row_num を振っていきます
1行毎にIFが評価されて、
同じdestination_idであれば、@row_num をインクリメント
違うdestination_idであれば、@row_num を初期化
↑これをMySQLのIFで実現しています。
IFは、引数1が真なら引数2、偽なら引数3を返します。
④1行前の destination_id を @_last_id に保持します。初回は空です
クエリ2を解説
return Destination::query()
->select([
'destinations.*',
'flights.name as flights_name'
])
->joinSub($flights, 'flights', function ($join) {
$join->on('destinations.id', '=', 'flights.destination_id') →⑤
->where('flights.row_num', 1); →⑥
})
->get();
⑤1つめのクエリを、Destinationsと結合します。
⑥順位付けした中の、1位のフライトのみ取得します。
こんな感じです。
やり方は違いますが、方法1と同じ結果が得られます。
ちなみに方法2の方は、selectに追加すれば到着時間も一緒に取得することができます。
番外編 Window関数を使う
Window関数を使うとどういうSQLになるのかも一応見ておきましょう。
クエリビルダでは対応できないので、Laravelでは生のSQLを書くことになります。
return DB::select("
SELECT
d.id as id
, d.name as name
, f.name as last_flight
FROM
destinations d
JOIN
(
SELECT *
, ROW_NUMBER() OVER (PARTITION BY destination_id
ORDER BY arrived_at DESC) AS row_num
FROM flights
) f
ON
d.id = f.destination_id
AND f.row_num = 1
");
以上、Laravel + MySQLでグループごとにランク付け/指定順位のデータだけ取得する方法を紹介しました!
関連リンク
Discussion