📌

Laravelで一括更新画面を作成する

2024/11/05に公開

はじめに

DBのCRUDを行う画面は一覧表示画面や新規登録画面など色々ある中で、一番ややこしいのが「複数件のデータを一括して更新する画面」ではないかと私は感じております
更新は登録と異なり更新時にその対象レコードが存在しているかのチェックが必要だったり、一件と異なり複数件だとバリデーションエラーをどう出すか、DBへの反映時もinsertやdeleteと異なり一発SQLで済ますには工夫が必要です
そんな一括更新画面をできる限り楽に作る方法を、例として複数台の車情報を一括更新する画面を作りながら紹介できればと思います

前提

Laravel 11.x
PHP 8.2
MySQL 8.0

参考までにWindows10, VirtualBox6.0, Ubuntu20.04, Docker 24.0を使用しています

carsテーブル

物理名 id license_no expiration odometer user_id
内容 ID ナンバー 車検満了日 総走行距離 使用者ID
BIGINT VARCHAR(4) DATE INT BIGINT

Car Model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Car extends Model
{
    protected function casts(): array
    {
        return [
            'expiration' => 'date',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

実装

一覧表示

ひとまず一覧を表示し、更新可能とする車検満了日・総走行距離はinput、使用者IDはselectにて変更しますが
Laravelの場合[]を末尾に付けたname属性にすると

<input name="expiration[]" value="first">
<input name="expiration[]" value="second">

Controllerでは以下のように配列形式にて値を取得できます

$request->input('expiration');
// ['first', 'second']

また、キーを指定することも可能です

<input name="expiration[f]" value="first">
<input name="expiration[s]" value="second">
$request->input('expiration.s');
// 'second'

https://laravel.com/docs/11.x/requests#retrieving-an-input-value

この配列形式でのname指定を活用して、複数行の入力項目を実装できればと思います

<table>
    <thead>
        <tr>
            <th>ナンバー</th>
            <th>車検満了日</th>
            <th>総走行距離</th>
            <th>使用者</th>
        </tr>
    </thead>
    <tbody>
        @foreach($cars as $car)
            <tr>
                <td>
                    {{ $car->license_no }}
                    <input type="hidden" name="id[]" value="{{ $car->id }}">
                </td>
                <td>
                    <input type="date" name="expiration[{{ $car->id }}]" value="{{ $car->expiration->format('Y/m/d') }}">
                </td>
                <td>
                    <input type="number" name="odometer[{{ $car->id }}]" value="{{ $car->odometer }}">
                </td>
                <td>
                    <select name="user_id[{{ $car->id }}]">
                        @foreach($users as $user)
                            <option value="$user->id" @selected($user->id === $car->user_id)>
                                {{ $user->name }}
                            </option>
                        @endforeach
                    </select>
                </td>
            </tr>
        @endforeach
    </tbody>
</table>

バリデーション

FormRequestにバリデーション定義を記載していきます
ここでも配列形式のものは.区切りでバリデーションを指定することができます

class BulkUpdateFormRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'id' => 'required|array',
            'expiration.*' => 'required|date',
            'odometer.*' => 'required|numeric',
            'user_id.*' => 'required|numeric',
        ];
    }

user_idで指定されたIDが実際にDBに存在しているかのチェックについてはrulesの中で'exists:users,id'を書いてあげても良いのですが
一括更新対象の行数分SQLが走ってしまうため、パフォーマンスが良いものでは無いです
rulesのバリデーション後に一括して存在チェックをかけたいと思います

class BulkUpdateFormRequest extends FormRequest
{
    public function rules(): array
    {
       ・・・
    }

    public function withValidator($validator)
    {
        $validator->after(function ($validator) {
            if ($validator->errors()->any()) {
                return; // 既にエラーがあれば処理終了
            }
            // 選択されたもののうち存在するuser_idを取得する
            $exist_user_ids = User::whereIn('id', collect($this->input('user_id'))->unique())
                ->get()
                ->pluck('id');
            foreach($this->input('user_id') as $car_id => $user_id) {
                if (!$exist_user_ids->contains($user_id)) {
                    // 存在しない場合エラー 
                    $validator->errors()->add('user_id.'.$car_id, 'エラーメッセージ');
                }
            }
        });
    }

bladeでのエラーメッセージやバリデーションエラー時の元入力値の保持についても、以下のように.区切りで指定可能です

<input type="number" name="odometer[{{ $car->id }}]" value="{{ old('odometer.' .$car->id, $car->odometer) }}">
@error('odometer.' .$car->id)
<p>{{ $message }}</p>
@enderror

更新処理

Controllerで更新して行きましょう。簡単にやろうとするなら以下のようにまとめて更新対象を取得し、値を当てこみ更新していきます

    public function bulkUpdate(BulkUpdateFormRequest $request): RedirectResponse
    {
        \DB::transaction(function () use ($request) {
            $cars = Car::whereIn('id', $request->input('id'))->get();
            foreach($cars as $car) {
                $car->expiration = $request->input('expiration.' .$car->id);
                $car->odometer = $request->input('odometer.' .$car->id);
                $car->user_id = $request->input('user_id.' .$car->id);
                $car->save();
            }
        });
        return redirect(・・・);
    }

例えば一覧に20件表示されていた場合、これだと取得SQL1回、更新20回が必ずかかるように見えますが、実際は値が変わったものだけが更新されていきます

https://github.com/illuminate/database/blob/11.x/Eloquent/Model.php#L1154

そのため一覧で表示する行数(レコード数)は多くとも、値を変更する行数が少ない場合はこの方法で良いかと思います
いっぽうで変更を加える行数が多くパフォーマンスを考慮する必要がある場合、MySQLだとELT・FIELDを用いた一括更新を行うことができます
ELTと聞いて頭の中にfr○gileやTime g○es byが浮かんだみなさん、そっちではなくMySQLの関数です
ELT・FIELDを使う場合はupdate時にDB::rawを使えば書くことが出来ます

$expirations = collect($request->input('expiration'));
Car::whereIn('id', $request->input('id'))->update([   
    // ELT(FIELD(id, 1,2,3), '2025/10/20','2024/12/24','2026/04/01')のようなSQLとなる
    'expiration' => \DB::raw('ELT(FIELD(`id`, ' .$expirations->keys()->join(',') ."), '" .$expirations->join("','") ."')"),
    'odometer' => ・・・
]);

ただし上記のとおりなかなかごちゃつくのと、(日付フォーマットのバリデーションをしているため今回は問題無いものの)フリー入力の項目の場合はSQLインジェクション対策が必要になるなど、考えることは多いです
また、上記のみだと値の変更が無くとも更新されてしまうため、それを防ぐとなるとModelのisDirtyメソッドを使って変更のあったもののみを対象とするなどの対策も必要です

https://laravel.com/docs/11.x/eloquent#examining-attribute-changes

最後に

最終的には要件にあわせて変えて行くところはあると思いますが、楽に最低限の一括更新機能が実装できたのではないでしょうか
やはり一番大きいのはinput要素のnameを配列で指定すると、取得等の部分では.区切りで指定できる所でしょうか
配列となっているため、LaravelのCollectionクラスと組み合わせることにより、少ないコード量で機能を実現できるかと思います
Laravelは機能が多くそれぞれを使い倒していく事で楽に記述ができるので、今後も色々な機能を学んでいきたいですね

Discussion