LaravelでEager Loadingしていたつもりが、まったくしていなくて泣いた


普段LT会のネタ用に取っておいたりするのですが、**感情が爆発**しているので産地直送でお届けします。

<font color="blue">追記：**未解決**でしたが**解決**しました</font>
# 泣くことになった経緯
趣味で「[かどで日記](https://kadode.usuyuki.net)」という日記サービスを運営していて、そこでのお話です。
## 戦犯となるページ
指定した月の日記を表示するページです。何の変哲も無いですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/6a234746-e8b1-1a70-4ff8-5f2af5bf0974.png)


## 環境
Laravel:9系
PHP:8.1


**今回の登場人物となるテーブル**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/0b3cc0fd-4f10-cc63-d2d3-4de863ae14a2.png)
**diariesテーブル**に日記の本文やタイトルなどが入っており、**statistic_per_datesテーブル**に日記の統計情報(固有表現情報、感情分析値など)が入っています。
ご覧の通りdiary_idで繋がってる形ですね。

※diary_processedsには日記の統計情報の元となる自然言語処理したデータ(形態素解析した情報など)が入っていますが今回は直接関係ないので割愛です。


## 具体的な処理
先程のページを作るために必要なのは、指定した月の日記とその統計情報の取得です。

日記と一緒にその日記の統計情報も表示したい訳なので、まずはDiaryモデルからwithできるように**HasOne**で関連付けておいて……
```php:app\Models\Diary.php
public function statisticPerDate(): HasOne
{
    return $this->hasOne(StatisticPerDate::class);
}
```

下のようにDiaryモデルをコントローラーで呼び出してbladeで表示する形ですね。
```php:app\Http\Actions\Diary\ShowMonthDiaryAction.php
final class ShowMonthDiaryAction extends Controller
{
    public function __construct(
        private GetMonthlyStatisticByMonth $getMonthlyStatisticByMonth,
        private GetDiariesByMonth $getDiariesByMonth,
    ) {
    }

    public function __invoke(int $year, int $month): View|Factory
    {
        $diaries = $this->getDiariesByMonth->invoke($year, $month);
        $statisticPerMonth = $this->getMonthlyStatisticByMonth->invoke($year, $month);

        return view('diary/archive/monthArchive', ['diaries' => $diaries, 'year' => $year, 'month' => $month, 'statisticPerMonth' => $statisticPerMonth]);
    }
}

```

といっても実際の処理はUseCasesでしてまして、下記のような形です。
```php:app\UseCases\Diary\GetDiariesByMonth.php
public function invoke(int $year, int $month): array
{
    $diaries = Diary::with('StatisticPerDate')->whereYear('date', $year)->whereMonth('date', $month)->orderby('date', 'desc')->get();

    $arrangedDiaries = [];
    foreach ($diaries as $diary) {
        $statisticStatus = $this->checkStatisticStatusByDiary->invoke($diary);
        $arrangedDiaries[] = $this->arrangeDiaryStatistic->invoke($diary, $statisticStatus)->toArray();
    }

    return $arrangedDiaries;
}
```
ここで日記のデータをDBから読み出しています。``Diary::with('StatisticPerDate')``することで統計情報が入ったテーブルの値も一緒に持ってくる形ですね。
**ループの中で統計情報が入ったテーブルをwhereで呼び出したりしていないなんて、えらいなぁ……**

ループの中で呼んでるcheckStatisticStatusByDiaryクラスでは日記の統計情報の状態をEnumで持たせることで表示の制御の処理をしやすくしています。

```php:app\UseCases\Diary\Statistic\CheckStatisticStatusByDiary.php
/**
 * 日記の統計情報のチェックを行い、Enumで返す.
 */
public function invoke(Diary $diary): StatisticStatus
{
    return match (true) {
        null === $diary->statisticPerDate => StatisticStatus::notExist,
        100 !== $diary->statisticPerDate->statistic_progress => StatisticStatus::generating,
        $diary->statisticPerDate->updated_at < $diary->updated_at => StatisticStatus::outdated,
        true => StatisticStatus::existCorrectly,
    };
}

```
必ずしも日記に対して統計情報が存在する訳ではないので(日記が作られた直後など)``null === $diary->statisticPerDate``のところで統計情報が入ってなかったら無いよっていうEnumの値にしている形ですね。


## そして実行結果を見る。
Laravel debuggerを使うと内部で生成されたSQLが見れます。さてさて、日記とその統計情報はwithで呼び出してるし**クエリ数も少なくできちゃう**んだよなぁ……！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/b12e478f-e906-a97f-186b-e4c08bcc4633.png)

…………！？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/87dab257-2b71-215b-3903-d21228741fcd.png)


34？？？

N+1問題のお手本みたいなクエリ数。

## 異常クエリの発生源をたどる
Laravel Debuggerではクエリを発行した位置まで示してくれるんですよ……(便利～)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/0c7fd235-3b1a-5762-74a8-e6ccd472a2f4.png)

この画像の1行目は統計情報をまとめて取っているクエリですね。
```sql
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` in (112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141)
```

発行場所は``app/UseCases/Diary/GetDiariesByMonth.php``です。
``Diary::with('statisticPerDate')``で生成されてますね。納得です。

2行目から、1行目でまとめて取ったデータを1つずつ再度データベースから取り出す謎の処理が走っています。なんだこれ……
```sql
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 141 and `statistic_per_dates`.`diary_id` is not null limit 1
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 140 and `statistic_per_dates`.`diary_id` is not null limit 1
-- 以下略
```
発行場所は``app/UseCases/Diary/Statistic/CheckStatisticStatusByDiary.php``です。
Enumの値を決定する``null === $diary->statisticPerDate => StatisticStatus::notExist``していることろでした……

## Eager Loading
Laravelでは``Diary::with('statisticPerDate')``のようにすることでEager Loadingの恩恵にあやかれます。
Eager Loadingは大雑把に言えば予め必要なデータを取ってきて、毎度クエリ叩かないようにするものです。(joinするわけではないので、最低1クエリは増えますが……)
逆に言えば、これ無しでは``$statistic = $diary->statisitcPerDate``みたいなコードを書くだけで裏側ではクエリを発行してデータを都度取ってきてしまいます。

ということは、本来であれば先程の画像のようなことは起きないはずです。**with**してますから。


予めそれぞれの日記の統計情報取ってきているので、そのあと再度
```sql
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 141 and `statistic_per_dates`.`diary_id` is not null limit 1
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 140 and `statistic_per_dates`.`diary_id` is not null limit 1
--　以下略
```
と同じデータを取ることは完全な無駄です。

なぜ………

物語は意外な結末を迎え~~ます~~る予定でしたが、迎えませんでした。


## HasOneとBelongsTo

一番最初にお見せしたコードに
```php:app\Models\Diary.php
public function statisticPerDate(): HasOne
{
    return $this->hasOne(StatisticPerDate::class);
}
```

こんなものがありました。``Diary::with('statisticPerDate')``するためにDiaryモデルに定義している他のモデルとの関連付けですね。
これにより``Diary::with``のように使うことで他のテーブルの情報も取ってこれる形です。

ご存知のとおりテーブル同士の関連付けには数種類のパターンがあります。
LaravelではHasOne,HasMeny,BelogsToみたいな形として表されます。

### HasOne
HasOneは1対1の関係を表します。
例えば日記(diaries)とその日記の統計情報(statistic_per_dates)の関係ですね。
```mermaid
graph TD;
日記1-->日記1の統計情報
日記2-->日記2の統計情報
日記3-->日記3の統計情報
```

RDBでは統計情報側(statistic_per_dates)にdiary_idというカラムを持たせて日記側(diaries)と紐付けられるようにします。こんな形。
```mermaid
graph LR;
statistic_per_dates-->|diary_id|diaries
```
※statistic_per_datesは日毎の統計データの意ですが、原則1つの日記は1つの日付なので1対1対応です

### HasMany
HasManyは1対多の関係を表します。
例えばユーザー(users)と日記(diaries)の関係ですね。

```mermaid
graph TD;
ユーザー1-->日記1
ユーザー1-->日記2
ユーザー1-->日記3
```
1人のユーザーに対して複数の日記が紐づきます。

RDBでは日記側にはuser_idというカラムを持たせて関連付けている形です。

```mermaid
graph LR;
diaries-->|user_id|users
```

### BelongsTo
BelongsToも1対1の関係を表しますがHasOneとは逆です。HasOneの例を流用すると
```mermaid
graph TD;
日記1の統計情報-->日記1
日記2の統計情報-->日記2
日記3の統計情報-->日記3
```
逆版ですね。

## つまり？
日記モデルDiaryから個別の統計情報モデルStatisticPerDiaryへは**HasOne**が適切だと考えられます。
ということで、日記モデルと日記の統計情報は
```php:app\Models\Diary.php
    public function statisticPerDate(): HasOne
    {
        return $this->hasOne(StatisticPerDate::class);
    }
```
と、最初に示した通りDiary.phpに書けばよい訳ですね。ですよね……？　
ちょっと不安なのでBelongsToでも書いてみようかな……

# BelongsToに変えてみた

```php:app/Models/Diary.php
public function statisticPerDate(): BelongsTo
{
    return $this->belongsTo(StatisticPerDate::class);
}
```
**これは違うはず**なんだけど…………

おもむろにローカルホストで開くと
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/8a5d324e-fdaf-c1f1-3cd4-f084a4f86710.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/2cace175-0fa3-3bfe-c9e7-728bd4e1d02e.png)


**4クエリ**になりました！！
兵どもが夢の跡。劇的ビフォーアフターですね。

いやー、**HasOneとBelongsToを間違えるなんて、おっちょこちょいだなぁ……**

・・・・・・・・？

・・・・・・・？

・・・・・・？

・・・・？

・・？

・？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/f838f256-d8f5-3774-0d86-ec3b2ef68f6b.png)



 ``where 0 = 1``？？？

…………


# 気付けないよそんなの……
Laravelだと変哲もないコードで実はクエリ発行してますとか、実はCarbonのインスタンス化やってますみたいな、まるで**外からは見えないけどこのケーキ屋さん、カフェも併設してる**みたいなノリがよくあります。中まで見ないと気付けないんですよ……

今回は月ごとの日記データだったので**高々30クエリ**な訳ですが、数が増えるに従ってO(n)的な形で増えていきます……[Laravelを低速化する技術](https://fortee.jp/phpcon-2022/proposal/1121ac50-2938-4e8d-9701-ce24963c38a8)の完成です。


結局のところ私のミスではあるのですが、HasOneとBelongsToを逆にしても、クエリ数が20増えたところで**9ミリ秒しか変わってなかった**ため、Debuggerでクエリでも確認しない限り**気づけなかった**……

# 解決していませんよ？？
執筆途中``where 0=1``に気づかず、無事クエリ4つに減って解決したし記事書いて寝ようと、たかを括っていました。
でも気づいてしまったんです。``where 0=1``に……
このクエリだと**テーブル内のすべての情報取ってきている**ので、**最初より悪化**しています。気づけてよかった……

やはりHasOneが正しい気がする。でもクエリ異常に増える……
分かりません……ぐぬぬです……もうEloquentやめてクエリビルダで取ってこようかな……

？？？「いかがでしたか？　調べた結果、よく分かりませんでした！」
これだけはしたくなかった……

# その謎を解明すべく、我々はアマゾンの奥地へ向かいかけた……
LaravelのEloquentなんて、読むだけでcomnpassのイベントになるほどの巨大な世界です。
ちょっとだけ足を踏み入れました。

## BelongsToを探ろうとしたけど最初から違ったので帰る

```php:app/Models/Diary.php
public function statisticPerDate(): BelongsTo
{
    return $this->belongsTo(StatisticPerDate::class);
}
```
こちらのbelongsToの引数は第1引数以外省略できるのですが、省略しない場合は第2引数にStatisticPerDateの外部キー、第3引数にDiaryの対応するキー名を入れる形です。
省略された場合は自動で探してくれて、

```php:laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasRelationships.php
//中略
if (is_null($foreignKey)) {
    $foreignKey = Str::snake($relation) . '_' . $instance->getKeyName();
}
//中略
$ownerKey = $ownerKey ?: $instance->getKeyName();
//中略
```
みたいな形で推測されます。結果として
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/d5000320-536b-cabf-4d5a-62264b68c491.png)

第2引数となる$foreignKeyに``statistic_per_date_id``、第3引数となる$localKeyに``id``が入っているみたいです。おっとこれはおかしい……
```mermaid
graph LR;
statistic_per_dates-->|diary_id|diaries
```
こうしたいのにぜんぜん違う……
この時点で明らかに違いますよね……自動で推論された結果が意図したものと反対なので、やはりBelongsToでなくHasOneを使うのが適切です。

Eloquent巨大すぎて間違った``$foreignKey``と``$ownerKey``がどうなれば 0 = 1になるのか辿るのは諦めました……ごめんなさい……この時点で違う訳ですから……
、どこかで暗黙的な型変換が走ってintキャストされたのかなみたいな想定しかできません……

## HasOneでやっぱり正しいよね……
hasOneもbelongsToと同様第2引数以降は省略できます。

```php:laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasRelationships.php
//中略
$foreignKey = $foreignKey ?: $this->getForeignKey();

$localKey = $localKey ?: $this->getKeyName();
//中略
```

hasOneのメソッド見たところ、書き方違ってびっくりです……モダンの牙を向けてきましたね……

で、結果は
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/db3834be-c95a-3833-a0b1-6acf2cccd689.png)

第2引数となる$foreignKeyに``diary_id``、第3引数となる$localKeyに``id``が入っているみたいです。そうそうこれですよ、これ……！
なのでやはりHasOneで間違いはなさそうです。

ただ、肝心のEager Loadingが効かない部分が辿れていません……Eloquentでかすぎる……

# 気づき
もしかしてControllerの継承クラスじゃないとだめなのかな……とか色々探って見たりしてましたが一向に改善の兆しが見えません。
いやー、ちゃんとwithしてるのになぁ……
```php
\DB::enableQueryLog();
$diaries = Diary::with('StatisticPerDate')->whereYear('date', $year)->whereMonth('date', $month)->orderby('date', 'desc')->get();
foreach ($diaries as $diary) {
    echo $diary->statisticPerDate->updated_at;
}
dd(\DB::getQueryLog());
```
Laravel Debuggerにバグがあって、実はクエリ数少ない説とかないのかな……とdd()でクエリを見たりもしたのですが、やはり異常なクエリ数は変わっていません。


わからないすぎる……ちゃんとwithしてるのになぁ……
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/d4a6541a-4a6e-03eb-6030-73b5d8f7b6c5.png)
ちゃんとHasOneにしてるのになぁ……
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/cf566160-c64a-7027-3111-71ca051afbb6.png)

………………

………


…！？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/6a6cc677-280a-4b34-c32b-6abef9befa6f.png)
あれ、先頭大文字になってません？？？？

``with()``の引数には文字列または配列でDiaryで定義したメソッド名を入れるものです。
ということで、本来ここは
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/02396684-b0b6-790f-afba-27e915bc7db4.png)
<font color="red">**s**</font>tatisticPerDateメソッドですから、<font color="red">**S**</font>でなく、<font color="red">**s**</font>が入るべきですよね。

さて、早速``Diary::with('statisticPerDate')``に変えてみると……
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621241/7532d185-a9dc-26ce-1797-4e1e0511a9a3.png)
はい……4クエリですね……Eager Loadingしてますね……
世界に平和と秩序がもたらされました。


# 結論
<font color="red">**メソッド名を間違えている**</font>です。

メソッド名が**間違っていても**ちゃんとEager Loadginのためのinクエリ
```sql
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` in (112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141)
```
が走っているのは罠すぎる……

高度な**Laravelを低速化する技術**ですね……

# おわり
Laravelは誰にも優しいんです。
with内のメソッド名が間違っていてもエラーなんて出しません。なんなら正しいクエリも発行してくれます(Eager Loadingは効かないけど……)。
Eager Loadingが効かなくても普通の読み込みをしてくれて何事もなかったかのように表示されます。

Laravel開発者、Taylor Otwellさんのイケメン笑顔が頭に浮かびます。
Taylorさんへの敬意を込めて、Qiitaの名前にTaylorさんのTwitterと同じ🪐を付けました。

おわり