Laravelのchunkメソッドで全レコードが更新されなかった話

:::note
## 概要
chunkは一度にクエリの結果の小さな塊（チャンク）を取得し、各チャンクをクロージャーに送り込んで処理します。
注意点としてはチャンクのクロージャー内でレコードの更新や削除を行う場合、主キーや外部キーの変更がチャンクのクエリ結果に影響する可能性があります。
:::

# chunkとは 💡

[公式 doc](https://laravel.com/docs/11.x/queries#chunking-results)

chunkメソッドは、一度にクエリの結果の小さな塊（チャンク）を取得し、各チャンクをクロージャーに送り込んで処理します。
何千ものデータベース・レコードを扱う必要がある場合に有効です。

例）usersテーブル全体を一度に100レコードのチャンクで取得
```php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
 
DB::table('users')->orderBy('id')->chunk(100, function (Collection $users) {
    foreach ($users as $user) {
        // ...
    }
});
```

:::note warn
chunkの注意点

[公式doc](https://laravel.com/docs/11.x/queries#chunking-results:~:text=When%20updating%20or%20deleting%20records%20inside%20the%20chunk%20callback%2C%20any%20changes%20to%20the%20primary%20key%20or%20foreign%20keys%20could%20affect%20the%20chunk%20query.%20This%20could%20potentially%20result%20in%20records%20not%20being%20included%20in%20the%20chunked%20results.)

チャンクのクロージャー内でレコードの更新や削除を行う場合、主キーや外部キーの変更がチャンクのクエリ結果に影響する可能性があります。
チャンクされた結果にレコードが含まれなくなり意図した処理ではなくなることがあります。
:::

今回はこれに該当する問題を起こしてしまっていました。。


# 意図した処理ができなかったchunk 🚨
```php
$query = Employee::query()->where('position','Developer');
$query->chunk(10, function ($employees) {
    foreach ($employees as $employee) {
        $this->info('Processing employee: ' . $employee->id);
        $employee->position = 'Manager';
        $employee->save();
    }
});
```

## クエリの説明
 employeesテーブルのpositionカラムがDeveloperのレコードを取得

##  chunk内のクロージャーの説明
 positionカラムをManagerに更新
 
## 意図していること
 全件のemployeesテーブルのpositionカラムがDeveloperのレコードをManagerに更新する

## 結果
123レコード中、63レコードしか更新されなかった

## ログでレコードのidを見る
以下のidの更新がスキップされているように見えます。
- 11~20
- 30~41
- 51~60
- 71~80
- 91~100
- 111~120

```php
Processing employee: 1
Processing employee: 2
Processing employee: 3
Processing employee: 4
Processing employee: 5
Processing employee: 6
Processing employee: 7
Processing employee: 8
Processing employee: 9
Processing employee: 10
Processing employee: 21
Processing employee: 22
Processing employee: 23
Processing employee: 24
Processing employee: 25
Processing employee: 26
Processing employee: 27
Processing employee: 28
Processing employee: 29
Processing employee: 30
Processing employee: 41
Processing employee: 42
Processing employee: 43
Processing employee: 44
Processing employee: 45
Processing employee: 46
Processing employee: 47
Processing employee: 48
Processing employee: 49
Processing employee: 50
Processing employee: 61
Processing employee: 62
Processing employee: 63
Processing employee: 64
Processing employee: 65
Processing employee: 66
Processing employee: 67
Processing employee: 68
Processing employee: 69
Processing employee: 70
Processing employee: 81
Processing employee: 82
Processing employee: 83
Processing employee: 84
Processing employee: 85
Processing employee: 86
Processing employee: 87
Processing employee: 88
Processing employee: 89
Processing employee: 90
Processing employee: 101
Processing employee: 102
Processing employee: 103
Processing employee: 104
Processing employee: 105
Processing employee: 106
Processing employee: 107
Processing employee: 108
Processing employee: 109
Processing employee: 110
Processing employee: 121
Processing employee: 122
Processing employee: 123
```

# なぜレコードがスキップされたのか ❓

## chunkで実行されるクエリを見る

```
select * from `employees`
where `position` = ? order by `employees`.`id` asc limit 10 offset 0 ["Developer"]

↑これが10件発行されます。

select * from `employees`
where `position` = ? order by `employees`.`id` asc limit 10 offset 10 ["Developer"]

↑次にこれが10件発行されます。

と続いていきます。
```

## わかったこと

1回目のチャンクでカラム値を更新すると、次のチャンクのクエリ結果では
idが11~20が先頭のレコードとして取得されます。
しかし、これは2回目のチャンクの為offset10によりスキップされます。
よって2回目ではidが21~30のレコードがクエリで取得されクロージャー内で更新されます。

これをくり返しています。

以下イメージです。


| id  | カラム値（取得時） | クロージャー内で更新されたカラム値 | チャンク番号 |
|-----|---------------------|----------------------------------|--------------|
| 1   | Developer             | Manager                        | 1            |
| 2   | Developer             | Manager                        | 1            |
| 3   | Developer             | Manager                        | 1            |
| 4   | Developer             | Manager                        | 1            |
| 5   | Developer             | Manager                        | 1            |
| 6   | Developer             | Manager                        | 1            |
| 7   | Developer             | Manager                        | 1            |
| 8   | Developer             | Manager                        | 1            |
| 9   | Developer             | Manager                        | 1            |
| 10  | Developer             | Manager                        | 1            |
| 11  | Developer           | ← offset10によりスキップ         | -            |
| 12  | Developer           | ← offset10によりスキップ         | -            |
| 13  | Developer           | ← offset10によりスキップ         | -            |
| 14  | Developer           | ← offset10によりスキップ         | -            |
| 15  | Developer           | ← offset10によりスキップ         | -            |
| 16  | Developer           | ← offset10によりスキップ         | -            |
| 17  | Developer           | ← offset10によりスキップ         | -            |
| 18  | Developer           | ← offset10によりスキップ         | -            |
| 19  | Developer           | ← offset10によりスキップ         | -            |
| 20  | Developer           | ← offset10によりスキップ         | -            |
| 21  | Developer           | Manager                          | 2            |
| 22  | Developer           | Manager                          | 2            |
| 23  | Developer           | Manager                          | 2            |
| 24  | Developer           | Manager                          | 2            |
| 25  | Developer           | Manager                          | 2            |
| 26  | Developer           | Manager                          | 2            |
| 27  | Developer           | Manager                          | 2            |
| 28  | Developer           | Manager                          | 2            |
| 29  | Developer           | Manager                          | 2            |
| 30  | Developer           | Manager                          | 2            |

# どうすれば良いのか 💡

## `orderBy('id')`でprimarykeyを指定して取得する
```php
$query = Employee::query()->orderBy('id')->where('position','Developer');
    $query->chunkByID(10, function ($employees) use ($query) {
        foreach ($employees as $employee) {
            $this->info($query->toSql()); // SQL文を表示
            $this->info(json_encode($query->getBindings()));
            $this->info('Processing employee: ' . $employee->id);
            $employee->position = 'Manager';
            $employee->save();
        }
    });
```

sqlは以下のようになっています

```sql
select * from `employees` where `position` = ? order by `id` asc ["Developer"]
```

## chunkByIdメソッドを使う

[公式doc](https://laravel.com/docs/11.x/queries#chunking-results:~:text=If%20you%20are,record%27s%20primary%20key%3A)

このメソッドは、レコードの主キーに基づいて自動的に結果をページ分割します。

```php
DB::table('users')->where('active', false)
    ->chunkById(100, function (Collection $users) {
        foreach ($users as $user) {
            DB::table('users')
                ->where('id', $user->id)
                ->update(['active' => true]);
        }
    });
```

sqlは以下のようになっています
```sql
select * from `employees` where `position` = ? ["Developer"]
```

# 参考 ✨

https://qiita.com/unknown_soldier/items/5427e5fab19936628a13

https://qiita.com/pakkun/items/1dbcfbf651963b016967

https://qiita.com/ToshiTechTech/items/4e9841e2240ead2f3c7e

https://kraf.jp/blog/5620

# 最後に
chunkの際にクロージャ内でレコードを更新する際には気をつけてという意味が身にしめてわかりました