LaravelのSoftDeletesが論理削除レコードを無視してくれる仕組み

# はじめに
本記事は[All About Group（株式会社オールアバウト） Advent Calendar 2022](https://qiita.com/advent-calendar/2022/allabout)の10日目の投稿です。
新卒エンジニアの@hinaism です。
今回はLaravel開発中にSoftDeletesに関するエラーに遭遇し、どういう仕組みで起こっているのかを探求してみた記録を記事にしてみました。

# 起こったこと
Eloquentを使ってデータを取得しようとしたところ、こんなエラーが出ました。
※テーブル、カラム、モデル等には仮名が含まれています。
```php
$table_1_object = Table1::find($request->route('column_1'));
```
```sql
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'table_1.deleted_at' in 'where clause' (SQL: select * from `table_1` where `table_1`.`column_1` = 17 and `table_1`.`deleted_at` is null limit 1)
```
特に言及した覚えのないdeleted_atで引っ掛かっており、生成されたSQL文にも「deleted_atがnullである」という条件が付加されているのがわかります。
Table1のモデルファイルを確認します。
```php
<?php

namespace App\Models\Eloquent;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Table1 extends Model
{
    use SoftDeletes;
    // テーブル名
    protected $table = 'table_1';

    ((省略))

}
```
SoftDeletesというtraitを使っています。

今回のエラーについての結論から言うと、テーブル設計が間違っていました。SoftDeletesを使う場合はテーブルにdeleted_atカラムを追加する必要があったのですが、それがなかったのが問題でした。

SoftDeletesとは、Laravelで論理削除を実現する機能です。削除処理として実際に該当レコードを削除する物理削除に対し、削除したことを示すカラムをレコードに追加して、そのレコードが削除されたものとして扱うのが論理削除です。モデルファイルで **SoftDeletesを使用**し、かつ、テーブルに**deleted_atカラム**（論理削除をおこなった日時データが入る）**を追加**することで動作します。

論理削除の仕組み自体は知っていたので、deleted_atというカラムが登場した時点で原因は想像がついたのですが、これだけではクエリに勝手に組み込まれる仕組みがわからなかったので、ソースコードを読んでみました。元のアプリはLaravel6でしたが、ちょうどアップデート予定があるのでLaravel9の方で読んでみます。

# LaravelのSoftDeletesが論理削除を反映する仕組み
## SoftDeletes.php
laravel/framework/src/Illuminate/Database/Eloquent/SoftDeletes.php
```php
<?php

namespace Illuminate\Database\Eloquent;

/**
 * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withTrashed(bool $withTrashed = true)
 * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyTrashed()
 * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutTrashed()
 */
trait SoftDeletes
{
    /**
     * Indicates if the model is currently force deleting.
     *
     * @var bool
     */
    protected $forceDeleting = false;

    /**
     * Boot the soft deleting trait for a model.
     *
     * @return void
     */
    public static function bootSoftDeletes()
    {
        static::addGlobalScope(new SoftDeletingScope);
    }

    /**
     * Initialize the soft deleting trait for an instance.
     *
     * @return void
     */
    public function initializeSoftDeletes()
    {
        if (! isset($this->casts[$this->getDeletedAtColumn()])) {
            $this->casts[$this->getDeletedAtColumn()] = 'datetime';
        }
    }
    ((省略))
```
bootやinitializeはtrait名と繋げることで、そのメソッドを元のモデルの呼び出しの際に自動で実行します。
initializeSoftDeletes()で呼ばれているgetDeletedAtColumn()を確認します。
```php
    ((省略))
    /**
     * Get the name of the "deleted at" column.
     *
     * @return string
     */
    public function getDeletedAtColumn()
    {
        return defined(static::class.'::DELETED_AT') ? static::DELETED_AT : 'deleted_at';
    }

    /**
     * Get the fully qualified "deleted at" column.
     *
     * @return string
     */
    public function getQualifiedDeletedAtColumn()
    {
        return $this->qualifyColumn($this->getDeletedAtColumn());
    }
    ((省略))
```
論理削除の実行日時を保持するカラム名をここで取得します。デフォルトだとdeleted_atになります。
getQualifiedDeletedAtColumn()はjoinなどを経てテーブル名を付加された最終的なdeleted_atのカラム名を返します。
今度は、bootSoftDeletes()で追加されているSoftDeletingScopeを見てみます。

## SoftDeletingScope.php
laravel/framework/src/Illuminate/Database/Eloquent/SoftDeletingScope.php
```php
<?php

namespace Illuminate\Database\Eloquent;

class SoftDeletingScope implements Scope
{
    /**
     * All of the extensions to be added to the builder.
     *
     * @var string[]
     */
    protected $extensions = ['Restore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed'];

    /**
     * Apply the scope to a given Eloquent query builder.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $builder
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @return void
     */
    public function apply(Builder $builder, Model $model)
    {
        $builder->whereNull($model->getQualifiedDeletedAtColumn());
    }
    ((省略))
```
Scopeは設定したモデルに関する全てのクエリに制約をつけるものです。ここでは、「deleted_atとして設定したカラムがnullである」という制約が付けられているのがわかります。

## SoftDeletesがクエリに反映される仕組み
モデルにSoftDeletesを設定する
↓
モデルにSoftDeletingScopeをつける
論理削除に用いるカラム(デフォルトでdeleted_at)を設定する
↓
SoftDeletingScopeに、「deleted_atカラムがnullである」という制約を設定する
↓
モデルの全てのクエリに上記制約がつく

ということになります。

ちなみにこれはEloquentで機能するものなので、DBファサードでクエリを書く場合は自動的に制約がつきません。今回引っかかったモデルは、本来論理削除に関する処理が必要でなかったのに加え、DBファサードでクエリを書いている部分しかなかったため、制約なく普通に動作してしまい、カラムがないのに気づかれなかったようです。

# まとめ
LaravelではEloquentの機能でモデルに論理削除を設定することができ、モデルファイルでSoftDeletesを使用する + deleted_atカラムをテーブルに追加することで使うことができます。SoftDeletesを使用するとモデルにScopeが追加され、クエリにdeleted_atがnullであるという制約が自動的につくようになり、論理削除されたレコードを無視します。

# 感想
先輩方が開発の過程で書いたコードは日々読んでいますが、フレームワーク自体のコードを読むことはあまりなく、良い体験になりました。実はScopeがほぼ初見だったので、今後の開発で利用できそうな機能にも触れることができてよかったです。「そういうもの」で済ませず深掘りしてみた価値があったと思います。

最後になりましたが、[All About Group（株式会社オールアバウト） Advent Calendar 2022](https://qiita.com/advent-calendar/2022/allabout)のこれまでの記事、明日以降の記事もぜひお楽しみください。

# 参考
https://readouble.com/laravel/9.x/ja/eloquent.html