🍒

Laravel で created_by, updated_by を自動で入れるのは思ったより辛かった件

2020/10/23に公開

検証環境

PHP 7.4
Laravel 8.10.0

前提知識

Model と QueryBuilder

Post::find(1)->update(['title' => 'hoge']);

Post::where('id', 1)->update(['title' => 'hoge']);

上記2つは同じことをやっているようで、実は全然違います。

前者は Model::find() の戻り値が Model オブジェクトなので、
Model::update() が実行されているのに対し、

後者は Model::where() の戻り値が QueryBuilder オブジェクトなので、
QueryBuilder::update() が実行されることになります。

モデルイベント

./vendor/laravel/framework/src/Illuminate/Database/Eloquent
あたりを fireModelEvent() で grep してみるとイメージが掴みやすいと思いますが、
基底クラスである Model や関連するトレイトには、
各処理の前後で独自の処理をコールできるイベントディスパッチャ的な仕組みが用意されています。

各Model の boot() や、
モデルに組み込んだトレイトの boot{TraitName}() によって、
イベントリスナを登録できます。

やりたいこと

Laravel は明示的に Model の $timestamp を false にしない限り、
created_at と updated_at を自動更新してくれます。

SoftDeletes トレイトを使用すれば、Model::delete() で
物理削除の代わりに deleted_at を更新してくれます。

これに加えて、前述のモデルイベントを使った方法で、
created_by, updated_by, deleted_by も自動更新されるようにしようとしました。
参考記事 : Laravelのeloquentのeventでcreated_byとかupdated_byとか更新するobserverとtrait

基本的には上記記事の実装そのままですが、
deleting で deleted_by をセットしただけでは値が反映されないため、

public function deleting(Model $model)
{
    $model->deleted_by = $this->getOperator();
    $model->save();
}

としました。

問題

ところが、INSERT や UPDATE の記述の仕方次第で、
各イベントが発火したりしなかったり、
そもそもタイムスタンプすら更新されなかったりする問題に直面しました。

検証

UPDATE

$post = Post::find(1);
$post->title = "changed"; //titleの値が変わる
$post->save();
//-> updated_at が更新される
//-> updating イベントが呼ばれる

$post = Post::find(2);
$post->title = "original"; //titleの値が変わらない
$post->save();
//-> updated_at が更新されない (!)
//-> updating イベントが呼ばれる

$post = Post::find(3);
$post->update(['title' => 'changed']); //titleの値が変わる
//-> updated_at が更新される
//-> updating イベントが呼ばれる

$post = Post::find(4);
$post->update(['title' => 'original']); //titleの値が変わらない
//-> updated_at が更新されない (!)
//-> updating イベントが呼ばれる

Post::where('id', 5)->update(['title' => 'changed']); //titleの値が変わる
//-> updated_at が更新される
//-> updating イベントが呼ばれない (!)

Post::where('id', 6)->update(['title' => 'original']); //titleの値が変わらない
//-> updated_at が更新される
//-> updating イベントが呼ばれない (!)

まず、QueryBuilder の update メソッドで更新したときは
updating イベントが呼ばれません。

上記のような単一行更新なら良いですが、
複数行を条件指定で更新したいような場合には、
一旦 Model の Collection を取得してループ処理するか、
自力で updated_by を更新する必要があります。

また、更新前後の値が全て同じ場合、
Model のメソッドで更新した場合はタイムスタンプが更新されないのに対し、
QueryBuilder のメソッドではタイムスタンプが更新されます。

INSERT

$post = new Post();
$post->title = 'hoge';
$post->save();
//-> created_at と updated_at が入る
//-> creating イベントと saving イベントが呼ばれる

$post = new Post();
$post->create(['title' => 'hoge']);
//-> created_at と updated_at が入る
//-> creating イベントと saving イベントが呼ばれる

$post = new Post();
$post->insert(['title' => 'hoge']);
//-> created_at も updated_at も入らない (!)
//-> creating イベントも saving イベントも呼ばれない (!)

insert() は各イベントが呼ばれないどころか、タイムスタンプさえ補完されませんでした。
一応 Model のメソッドなのに・・・
ちょっとこれは整合性が取れていない感が否めません。

DELETE

$post = Post::find(1);
$post->delete();
//-> deleted_at が入る
//-> deleting イベントが呼ばれる

Post::where('id', 2)->delete();
//-> deleted_at が入る
//-> deleting イベントが呼ばれない (!)

こちらも update と同じく、
QueryBuilder::delete() だとイベントが呼ばません。

対応

コーディング規約での対応

Model::save()
Model::create()
Model::update()
は使っていいけど、

Model::insert()
QueryBuilder::update()
はダメと、プロジェクトのコーディング規約で縛る必要が生じます。

また、複数行処理する場合はループで回すか、自力で updated_by を入れろ、と。

しかし、冒頭で解説した通り、
特に Model::update() と QueryBuilder::update() は一見してみわけづらく、
実装者も意識していないことが多いため、かなり不安が残ります。

特定の場合だけ自分で updated_by をセットしなければいけない、等というのも、
非常に忘れられやすそうです。

Model::insert() をオーバーライドする

まずは1番手っ取り早そうな insert() からやっつけます。

以下、BaseModel は全てのモデルで継承しているものとします。

namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

abstract class BaseModel extends Model
{
    public static function insert($attributes)
    {
        $attributes['created_at'] = DB::raw('CURRENT_TIMESTAMP');
        $attributes['created_by'] = $this->getOperator();
        $attributes['updated_at'] = DB::raw('CURRENT_TIMESTAMP');
        $attributes['updated_by'] = $this->getOperator();
	return (new static)->forwardCallTo((new static)->newQuery(), 'insert', [$attributes]);
    }

    //...
}

そのまま create() に渡すか、いっそ例外を投げてしまうことも考えましたが、

本来の仕様と違う動きをする($fillableの影響を受ける)のは実装者の混乱を招きますし、
バッチ処理など、$fillable を無視して例外的に insert したい場合もあると思いますので、
値を補完する方法を選びました。

ちなみに最後のところで parent::insert($attributes); とやると、基底 Model の __callStatic() 経由で再度このメソッドが呼ばれてしまい、無限ループに陥ります。

QueryBuilder::update() と QueryBuilder::delete() の代替を用意する

QueryBuilder (EloquentBuilder) には macro() によって
独自のメソッドを追加できる仕組みが用意されています。

// App\Providers\AppServiceProvider

public function boot()
{
    Builder::macro('updateWithWho', function ($attributes) {
        $attributes['updated_by'] = Auth()->user->id ?? 0;
        $this->update($attributes);
    });
    Builder::macro('deleteWithWho', function () {
        $this->update(['deleted_by' => Auth()->user->id ?? 0]);
        $this->delete();
    });

    //...
}

update() の代わりに updateWithWho()
delete() の代わりに deleteWithWho() を使うようにすればOK。

しかし結局、コーディング規約で縛る必要が出てきます。

別案 : QueryBuilder をオーバーライドする

少々乱暴な方法ではありますが、
QueryBuilder::update() と
QueryBuilder::delete() の挙動を変えてしまえれば、1番話が早いです。

namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Builder;

class CustomBuilder extends Builder
{
    public function update($values)
    {
        $values['updated_by'] = $this->getOperator();
        parent::update($values);
    }

    public function delete()
    {
        $this->update(['deleted_by' => $this->getOperator()]);
        parent::delete();
    }
}

その上で、Builder オブジェクトを生成している箇所を、乗っ取ります。

namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class BaseModel extends Model
{
    public function newEloquentBuilder($query)
    {
        return new CustomBuilder($query);
    }

    //...

なぜ上記のメソッドをオーバーライドするに至ったのかは、長くなるので割愛しますが、

Illuminate\Database\Eloquent\Model::__call()
から辿ってみれば、見つけられると思います。

(本当にここ一箇所だけのオーバーライドで足りるのかは、少々自身がありませんが・・・。)

それでも残る問題

同値更新の挙動

検証結果からわかる通り、Model::save() と Model::update() は、
値が変更されない限りタイムスタンプを更新しません。

しかし、updating イベントは発火するため、
これにバインドした処理によって updated_by を書き換えると、
値の変更があったとみなされ、タイムスタンプが更新されてしまいます。

つまり、
・最終更新ユーザが何も変更せずに更新 → タイムスタンプは更新されない
・最終更新ユーザ以外のユーザが何も変更せずに更新 → タイムスタンプが更新される
という意味不明な仕様となります。

論理削除時の挙動

deleted イベントの中で save() しているため、
これによって updated_at も更新され、
また updating イベントも発火するので updated_by も更新されることになります。

さほど大きな問題ではないかもしれませんが、それなら boolean の is_deleted でえぇやん、となります。

雑感

各テーブルに作成者・更新者を持つってわりとポピュラーな実装だと思いますし、
実際、保守の場面でこれに助けられることも少なくありません。

その実装に Laravel でこんなに力技を強いられるのは、正直予想外でした。

何よりモデルイベントの実装が、
イベントディスパッチャとしてはあまりにも不十分だと思いました。マル。

Discussion