Laravel における with の使い所 / 使うべきでない所

下記のようなコードがあると仮定します。

```php
$tweet = Tweet::findOrFail($tweet_id);
foreach ($tweet->replies as $reply) {
  echo $reply->user->name, "\n";
}
```

<details><summary>モデルの実装例</summary><div>

```php
class User extends Model {
  protected $fillable = ['name'];
  function tweets() {
    return $this->hasMany(Tweet::class);
  }
}
class Tweet extends Model {
  protected $fillable = ['content', 'user_id', 'parent_id'];
  function user() {
    return $this->belongsTo(User::class);
  }
  function replies() {
    return $this->hasMany(Tweet::class, 'parent_id');
  }
  function parent() {
    return $this->belongsTo(Tweet::class, 'parent_id');
  }
}
```

</div></details>

上記のコード例で発行される SQL は下記のようになります。

```sql
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- Tweet::findOrFail($tweet_id)
SELECT * FROM tweets WHERE parent_id IN (?); -- $tweet->replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
```

*おおっと！*
replies が 6 件だったので良かったですが、これが 1000 件だったら？

# N + 1 問題

replies を全件取得したあと（ SQL のクエリ１回 ）

```sql
SELECT * FROM tweets WHERE parent_id IN (?); -- $tweet->replies
```

`foreach` の中で関連する user の取得が replies の数だけ発生する…（ SQL のクエリ N 回 ）。

```sql
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
```

このようにクエリがたくさん発行される問題を **N + 1 問題** と呼びます。

# Eager ローディングを使う

ここで `with` を使ってみます。

```php
$tweet = Tweet::with('replies.user')->findOrFail($tweet_id);
foreach ($tweet->replies as $reply) {
  echo $reply->user->name, "\n";
}
```

```sql
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- findOrFail($tweet_id)
SELECT * FROM tweets WHERE parent_id IN (?); -- with('replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
```

`with` を使うことでクエリ数が減りました。

# `with` を使うべきでない場合

すべての場合において `with` を使うべきでしょうか？

```php
$tweet = Tweet::with('user', 'replies.user')->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
  echo $tweet->user->name, "\n";
  foreach ($tweet->replies as $reply) {
    echo $reply->user->name, "\n";
  }
} else {
  echo "too many replies!\n";
}
```

```sql
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- findOrFail($tweet_id)
SELECT * FROM users WHERE id IN (?); -- with('user')
SELECT * FROM tweets WHERE parent_id IN (?); -- with('replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
```

さて今回の例において replies が 6 件だった場合
下記は `false` になるので…

```php
if ($tweet->replies->count() <= 5) {
```

…下記の２つのクエリが **無駄に実行** されています。

```sql
SELECT * FROM users WHERE id IN (?); -- with('user')
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
```

# Lazy ローディング / Lazy Eager ローディングを使う

これを修正すると下記のようになります。

```php
$tweet = Tweet::findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
  echo $tweet->user->name, "\n"; // Lazy ローディング
  $tweet->loadMissing('replies.user'); // Lazy Eager ローディング
  foreach ($tweet->replies as $reply) {
    echo $reply->user->name, "\n";
  }
} else {
  echo "too many replies!\n";
}
```

replies が 6 件だった場合のクエリは下記のようになり…

```sql
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id = ?;
```

replies が 5 件だった場合のクエリは下記のようになります。

```sql
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id = ?;
-- 下記の２つのクエリは必要なときだけ実行される
SELECT * FROM users WHERE id = ? LIMIT 1;
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
```

# まとめ

Laravel のリレーションは必要になるまで取得されません。
そのため N + 1 問題が起こることがあります。
`with` や `load` は N + 1 問題 を防ぐために使いましょう。

不要な場合はできるだけ使うのを避けた方が良いです。
必要ないのに取得するため無駄にクエリが発行されるかもしれません。

# 参考

https://laravel.com/docs/8.x/eloquent-relationships#eager-loading
https://laravel.com/docs/8.x/eloquent-relationships#lazy-eager-loading

# 補足１

Laravel でない ORM の場合は
必要そうなリレーションに片っ端から with を指定する利点があるかもしれません。

Laravel で

```php
Tweet::with('user')->findOrFail($tweet_id)
```

を実行すると下記の SQL が発行されますが…

```sql
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM users WHERE id IN (?);
```

たとえば Sequelize で

```ts
Tweet.findOne({ where: { id: tweetId }, include: [Tweet.associations.user] })
```

を実行すると下記の SQL が発行されます。

```sql
SELECT `tweet`.`id`,
  `tweet`.`content`,
  `tweet`.`created_at` AS `createdAt`,
  `tweet`.`updated_at` AS `updatedAt`,
  `tweet`.`user_id` AS `userId`,
  `tweet`.`parent_id` AS `parentId`,
  `user`.`id` AS `user.id`,
  `user`.`name` AS `user.name`,
  `user`.`created_at` AS `user.createdAt`,
  `user`.`updated_at` AS `user.updatedAt`
FROM `tweets` AS `tweet`
LEFT OUTER JOIN `users` AS `user` ON `tweet`.`user_id` = `user`.`id`
WHERE `tweet`.`id` = ?;
```

# 補足２

今回の例の場合は、より適切な実装があります。

```php
$tweet = Tweet::with(['replies' => fn($q) => $q->take(6)])->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
  $tweet->loadMissing('replies.user');
  echo $tweet->user->name, "\n";
  foreach ($tweet->replies as $reply) {
    echo $reply->user->name, "\n";
  }
} else {
  echo "too many replies!\n";
}
```

<details><summary>php 7.3 以前の場合</summary><div>

```php
$tweet = Tweet::with(['replies' => function($q) { $q->take(6); }])->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
  $tweet->loadMissing('replies.user');
  echo $tweet->user->name, "\n";
  foreach ($tweet->replies as $reply) {
    echo $reply->user->name, "\n";
  }
} else {
  echo "too many replies!\n";
}
```

</div></details>

```sql
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id IN (?) LIMIT 6;
-- replies が 5 件以下だった場合 下記の２つのクエリも実行される
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
SELECT * FROM users WHERE id = ? LIMIT 1;
```

あるいは（クエリの実行数は増えますが）下記の方が良いかもしれません。

```php
$tweet = Tweet::findOrFail($tweet_id);
if ($tweet->replies()->count() <= 5) { // replies が replies() になってます
  $tweet->loadMissing('replies.user');
  echo $tweet->user->name, "\n";
  foreach ($tweet->replies as $reply) {
    echo $reply->user->name, "\n";
  }
} else {
  echo "too many replies!\n";
}
```

```sql
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT COUNT(*) AS aggregate FROM tweets WHERE parent_id = ?;
-- replies が 5 件以下だった場合 下記の３つのクエリも実行される
SELECT * FROM tweets WHERE parent_id IN (?);
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
SELECT * FROM users WHERE id = ? LIMIT 1;
```