Laravel 全テーブルをTruncateしてからシーディングする方法

## 追記: 2023/10/20

https://qiita.com/ucan-lab/items/9a67ee103c9f455de1de

リニューアル記事を書きました！

## はじめに

[Laravel Advent Calendar 2020 - Qiita](https://qiita.com/advent-calendar/2020/laravel) の 1日目 の記事です。
明日は @yoshikyoto さんの[レガシーなプロダクトに Laravel を導入する第一歩（Laravel DI と Facade）](https://www.utakata.work/entry/laravel/for-legacy)の記事です！

## 環境

- PHP: 7.4.12
- Laravel: 8.16.1
- MySQL: 8.0.21
- doctrine/dbal: 3.0.0

## 予備知識

### Laravel Seeding(シーディング) とは

Laravelにて標準搭載されている初期データ(テストデータ)を登録する機能です。

- https://readouble.com/laravel/8.x/ja/seeding.html

### Laravel Migration(マイグレーション) とは

Laravelにて標準搭載されているデータベースのテーブル定義の変遷のバージョン管理をしてくれる機能です。

- https://readouble.com/laravel/8.x/ja/migrations.html

## 困ったこと

### Laravel migrate:fresh コマンド

`migrate:fresh` コマンドはすべてのテーブルをドロップして、すべてのマイグレーションを再度実行してくれるコマンドです。

```shell-session
$ php artisan migrate:fresh

# --seed オプションを付けるとシーディングの実行まで行ってくれます。
$ php artisan migrate:fresh --seed
```

マイグレーションはプロジェクトの開発が進めば進むほど、履歴が長くなってしまいまっさらな状態を作るためにマイグレーションファイルをすべて適応するのはどんどん長くなってきてしまいます。

[ダカーポ](https://qiita.com/ucan-lab/items/8d23dc08fc5f964a3e72)を使ったり、Laravel8で新しく実装された[マイグレーションスカッシング](https://qiita.com/ucan-lab/items/2f00191dcf8371347d13)を使う手もありますが、もっと手軽に実行できる方法が我々には求められていました。

### Laravel db:seed コマンド

Laravelのシーディングのみ行ってくれるコマンド。

```shell-session
$ php artisan db:seed
```

テストデータが入った状態でシーディングし直そうとするも、古いレコードが残ったり、外部キー制約でうまく削除できない問題が起こる。

```shell-session
$ php artisan migrate:fresh
```

で解決するが、マイグレーション適用時間がかかってしまう。
やはり、このコマンドだけでデータをクリアする方法が我々には求められていた。

## 解決方法

全テーブルを `truncate` するシーダーを作ろう。

```shell-session
$ composer require doctrine/dbal
```

`doctrine/dbal` ライブラリが必要です。
テーブル定義を変更する場合には必須なライブラリなので、この機能を求める頃にはおそらく入っているはず...

```shell-session
$ php artisan make:seeder TruncateAllTables
```

生成された `database/seeds/TruncateAllTables.php` ファイルを下記のように上書きします。

```php:database/seeds/TruncateAllTables.php
<?php declare(strict_types=1);

use Illuminate\Database\Seeder;

class TruncateAllTables extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run(): void
    {
        Schema::disableForeignKeyConstraints();

        foreach ($this->getTargetTableNames() as $tableName) {
            $this->command->getOutput()->writeln("<comment>Truncating:</comment> {$tableName}");
            DB::table($tableName)->truncate();
            $this->command->getOutput()->writeln("<info>Truncated:</info>  {$tableName}");
        }

        Schema::enableForeignKeyConstraints();
    }

    /**
     * @return array
     */
    private function getTargetTableNames(): array
    {
        $excludes = ['migrations'];
        return array_diff($this->getAllTableNames(), $excludes);
    }

    /**
     * @return array
     */
    private function getAllTableNames(): array
    {
        return DB::connection()->getDoctrineSchemaManager()->listTableNames();
    }
}
```

やってることはシンプルで全テーブル名を取得して各テーブルで`truncate`して削除しています。
`migrations` テーブルを消す訳には行かないので除外してます。

外部キー制約が張られてあるとレコードを削除できない場合があるので一時的に無効化してます。
テーブル削除後に外部キー制約を有効化してます。

作成した `TruncateAllTables` クラスを `DatabaseSeeder` クラスに登録します。

```php:database/seeds/DatabaseSeeder.php
<?php declare(strict_types=1);

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run(): void
    {
        $this->call(TruncateAllTables::class);

        // シーダーをたくさん書く ...
        \App\Models\User::factory(10)->create();
    }
}
```

コマンドを実行すればokです。

```shell-session
$ php artisan db:seed

Seeding: Database\Seeders\TruncateAllTables
Seeded:  Database\Seeders\TruncateAllTables (153.14ms)
Database seeding completed successfully.
```

これでシーディングを実行しても古いレコードが残ったり、外部キー制約で怒られることもなくなりました。我々の完全勝利だ！🍣🍺
ちなみに `TruncateAllTables` だけ呼び出したい場合はクラス名をオプション指定して実行すればokです。

```
$ php artisan db:seed --class=TruncateAllTables

Database seeding completed successfully.
```

## 参考

- https://laravel.com/api/8.x/Illuminate/Database/Schema/Builder.html#method_enableForeignKeyConstraints
- https://laravel.com/api/8.x/Illuminate/Database/MySqlConnection.html
- https://github.com/doctrine/dbal/blob/3.0.x/src/Schema/MySQLSchemaManager.php
- https://github.com/doctrine/dbal/blob/3.0.x/src/Schema/AbstractSchemaManager.php#L213-L228

## 補足: Laravel7 以前のコード例

Laravel8の場合、名前空間が付いてるのでLaravel7以前で利用する場合は注意が必要です。

```php:database/seeders/DatabaseSeeder.php
<?php declare(strict_types=1);

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run(): void
    {
         $this->call(TruncateAllTables::class);

         $this->call(UserSeeder::class);
    }
}
```

```php:database/seeds/TruncateAllTables.php
<?php declare(strict_types=1);

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

class TruncateAllTables extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run(): void
    {
        Schema::disableForeignKeyConstraints();

        foreach ($this->getTargetTableNames() as $tableName) {
            $this->command->getOutput()->writeln("<comment>Truncating:</comment> {$tableName}");
            DB::table($tableName)->truncate();
            $this->command->getOutput()->writeln("<info>Truncated:</info>  {$tableName}");
        }

        Schema::enableForeignKeyConstraints();
    }

    /**
     * @return array
     */
    private function getTargetTableNames(): array
    {
        $excludes = ['migrations'];
        return array_diff($this->getAllTableNames(), $excludes);
    }

    /**
     * @return array
     */
    private function getAllTableNames(): array
    {
        return DB::connection()->getDoctrineSchemaManager()->listTableNames();
    }
}
```

## 補足: 速度のお試し

- マイグレーションファイル数: 45
- テーブル数: 24

上記の環境だと約2.5倍ほどの速度差が見られました。
もし実際に試した人がいたらコメント欄で実行時間教えてもらえると嬉しいです。

```
$ time php artisan migrate:fresh

real	0m5.013s
user	0m0.327s
sys	0m0.178s
```

約5.0秒

```
$ time php artisan db:seed --class=TruncateAllTables

real	0m2.274s
user	0m0.273s
sys	0m0.145s
```

約2.2秒

## 補足: db:wipe

6.xで`db:wipe`コマンドが追加されましたが、これだとテーブルがdropされてしまうので、マイグレーションを実行しなおさないといけなくなります。

```shell-session
$ php artisan db:wipe
```

https://github.com/laravel/framework/pull/29620