SQL クエリとコレクションの境界
次の2つは同じ結果を返すが、実行されるSQLクエリが異なる。
$users = User::where('id', '<', 2)->get(); // パターン1
$users = User::get()->where('id', '<', 2); // パターン2
tinker で確認してみる。クエリログを確認するため最初に DB::enableQueryLog()
を実行する。
この記事にそったプロジェクトを作成するには こちらの記事 を参照。
$ php artisan tinker
Psy Shell v0.11.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> DB::enableQueryLog()
=> null
>>> User::where('id', '<', 2)->get()
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> Illuminate\Database\Eloquent\Collection {#3587
all: [
App\Models\User {#3589
id: 1,
name: "渡辺 零",
email: "hanako.kondo@example.org",
company_id: 1,
email_verified_at: "2022-05-25 10:10:22",
#password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
#remember_token: "oPnq5DLKtc",
created_at: "2022-05-25 10:10:22",
updated_at: "2022-05-25 10:10:22",
},
],
}
>>> User::get()->where('id', '<', 2)
=> Illuminate\Database\Eloquent\Collection {#3584
all: [
App\Models\User {#3662
id: 1,
name: "渡辺 零",
email: "hanako.kondo@example.org",
company_id: 1,
email_verified_at: "2022-05-25 10:10:22",
#password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
#remember_token: "oPnq5DLKtc",
created_at: "2022-05-25 10:10:22",
updated_at: "2022-05-25 10:10:22",
},
],
}
どちらも結果は同じ。
しかし、クエリログを確認してみると2つは異なり、パターン2では where 句がクエリに含まれない。
where メソッドが get メソッドの前にあるか後にあるかで動作が異なっている。
>>> DB::getQueryLog()
=> [
[
"query" => "select * from `users` where `id` < ?",
"bindings" => [
2,
],
"time" => 1.85,
],
[
"query" => "select * from `users`",
"bindings" => [],
"time" => 0.75,
],
]
where が get の前であれば where は DBビルダーのメソッド であり、クエリに where 句が含まれるが、get の後ではクエリに where 句が含まれない。
1行で書くと同じように見えてしまうが、get でコレクションを取得し、where はそのコレクションを絞り込む コレクションのメソッド だ。
$users = User::get()->where('id', '<', 2);
// 同じ処理をロジックを分けて書くと
$collection = User::get(); // SELECT * FROM users の実行
$users = $collection->where('id', '<', 2); // コレクションの絞り込みで、SQL は実行されない
一般的な SQL 構文の良し悪しとしてはクエリ絞り込みがされるパターン1のほうが推奨されている。今回はデータが少ないのでパターン2のほうがクエリ実行速度が速かったが、データが数万件あったとしたら1レコードの結果を得るために全件取得するのは馬鹿らしい。
だが、「パターン2の書き方をしてはいけない」とは思考停止 してはならない。
コレクションは使いまわせ
コレクションを理解した上で使用すると、パターン2の真意が見えてくる。
まず、変数 $users にモデルの全オブジェクトをコレクションとして格納する。ここで全件取得の SQL クエリが実行される。
# php artisan tiker
Psy Shell v0.11.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> $users = User::get()
=> Illuminate\Database\Eloquent\Collection {#4209
all: [
App\Models\Eloquent\User {#4176
id: 1,
~~(省略)~~
コレクションに対する条件の絞り込みを変えていろいろ取得してみる。
何度繰り返しても、もう SQL クエリは実行されないし(※)、$users そのものは変化しない。
>>> $users->firstWhere('id', 1)->email
=> "hanako.kondo@example.org"
>>> $users->firstWhere('id', 2)->company_id
=> 1
>>> $users->firstWhere('id', 2)->company->name
=> "株式会社 渡辺"
>>> $users->pluck('company_id')->unique()
=> Illuminate\Support\Collection {#3590
all: [
0 => 1,
5 => 2,
13 => 3,
21 => 4,
31 => 5,
41 => 6,
46 => 7,
51 => 8,
55 => 9,
60 => 10,
],
}
※ 今回の例では、company->name
を表示したところで、select * from companies where id = ?
がクエリ実行されている。N+1問題については別の記事で。
コレクションに対するメソッドの種類は Laravelドキュメント を参照。絞り込みや集計などさまざまだ。
使用例
例えば、ユーザーの登録日によって異なるメールを送信するなど。
1年未満とそれ以降にわける場合に、 SQL クエリを2回実行する必要はない。
// ユーザーのコレクションを取得する
$users = User::where('email', '<>', '')->get();
// コレクションを条件によって絞り込む
foreach ($users->where('created_at', '<', now()->subYear()) as $user) {
// メール送信処理
}
foreach ($users->where('created_at', '>=', now()->subYear()) as $user) {
// メール送信処理
}
間違ったコード。get の位置が微妙に異なる。
// get()がないのでクエリオブジェクトの途中
$users = User::where('email', '<>', '');
// 条件をつけてクエリオブジェクトを完結する
foreach ($users->where('created_at', '<', now()->subYear())->get() as $user) {
// メール送信処理
}
foreach ($users->where('created_at', '>=', now()->subYear())->get() as $user) {
// メール送信処理
}
変数 $users はユーザーのコレクションではなく、この場合はクエリオブジェクトである。これが意図したコードなら変数名は $query
とするべきだろう。
クエリオブジェクトなので get() を実行するごとに SQL クエリが走る。しかも、where() はその都度オブジェクトに加算され、2回目の判定は意図したものにはならない。二度判定したいなら、先にオブジェクトのクローンを作成する必要がある。
コレクションは統計集計が大得意
とあるテーブル(とそのサブテーブル)のレコードの各種項目に対し、今月の合計、先月の合計、3ヶ月平均、縦計、横計・・・などなどを、1つのレスポンスとして出力しなければならないとき、どのようなSQLクエリを考えるだろうか? 無理やりに1回のクエリを書く? 条件に分けてクエリをいくつも書く?
集計する値によって JOIN するサブテーブルが都度変わってくるようなら、1つのクエリを記述するのはもう無理だろう。
具体的なテーブル構造やコードサンプルは書かないが・・・
- テーブル同士の結びつきは、Eloquent のレレーションを定義する。
- ざっくりとコレクションを取得するシンプルなコードを最初に実行。
- あとは欲しいだけコレクションに対する絞り込みと集計するコードを記述する。
どんな複雑な集計を行っても、絞り込み条件を変えて何度ループを繰り返しても、SQL クエリは最初の1回しか実行されないのだった。