【Laravel】データベース定義を元に雑に全モデルのテストデータをINSERTするスクリプト

 データベースを取り扱う時、テストを行うにはテストデータが必須です。このテストデータを自前で用意する必要がある時は概ねテストデータを生成できるツールを使うのですが、そのうちの多くがテストデータ生成用の定義をどこかしらに記述する必要があります。データベース定義が巨大な場合、いちいちテストデータ生成方法を定義をするのは手間なので、データベースを元にテストデータを生成したくなります。この目的をいくらか果たせるコマンドを紹介します。
 コードの概要としては Laravel の Eloquent と Doctrine の DBAL を元にデータベース定義を読み取り、それに合わせてランダムなテストデータを生成し、INSERT します。外部キー制約を無視したり、データベースの定義上入りうる値は問答無用で入れてしまう様なざっくばらんな作りですが、検索機能などとりあえずテストデータを早く大量に用意したい、といった時には便利です。
 Eloquentの準備 8.x Laravel
 Database Abstraction Layer – Doctrine: PHP Open Source Project
 前提となるカラムの型定義付きモデルの自動生成には infyom が便利です。
InfyOmLabs/laravel-generator: InfyOm Laravel Generator – API, Scaffold, Tests, CRUD Laravel Generator
 実装は次です。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Arr;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Index;
use Illuminate\Database\QueryException;
use Str;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

class MakeTestData extends Command
{
    protected $name = 'dev:make_test_data';
    protected $description = 'モデルを元に雑にテストデータを生成する';

    /**
     * コマンドオプション
     *
     * @return array
     */
    protected function getOptions(): array
    {
        return [
            new InputOption('all', 'a', InputOption::VALUE_NONE, '全モデルのテストデータを生成'),
            new InputOption('model', 'm', InputOption::VALUE_OPTIONAL, 'モデルを指定。namespace含むクラス名'),
            new InputOption('count', 'c', InputOption::VALUE_OPTIONAL, '生成する1テーブル毎のテストデータの個数', 100),
            new InputOption('fresh', 'f', InputOption::VALUE_NONE, 'テーブルをリセットする',),
        ];
    }

    /**
     * Execute the console command.
     *
     * @return int
     * @throws Exception
     * @throws \Exception
     */
    public function handle(): int
    {
        if($this->option('model')) {
            // 任意のモデルのクラス名を対象に取る
            $classNames = [$this->option('model')];
        } elseif($this->option('all')) {
            // 全モデルのクラス名を対象に取る
            $classNames = self::getEloquentClassNames();
        } else {
            // 対象が存在しない場合はエラー
            $this->error('--all オプションを付けるか、-t [テーブル名] でテーブル名を指定する必要があります');
            return static::FAILURE;
        }

        // 1テーブルに何個レコードを作るかセット
        $n = $this->option('count');
        foreach($classNames as $className) {
            $this->info($className);// 進捗把握のために表示
            // あるモデルについてのテストデータ生成と保存をまとめたメイン処理を始める
            $this->main($className, $n);
        }

        return static::SUCCESS;
    }

    /**
     * 固定値的な特別なレコードはここを起点に直書きする
     * @param  Model  $model
     * @return Model
     */
    private function makeSpecialRecord(Model $model): Model
    {
        // ログインに使うテスト用管理者の例
        if($model instanceof \App\Models\Eloquents\Admin) {
            $model->name = 'テスト管理者';
            $model->password = \App\Models\Eloquents\Admin::makePassword('password');
        }

        return $model;
    }

    /**
     * @param  string  $modelClassName
     * @param  int     $n
     * @throws Exception
     * @throws \Exception
     */
    protected function main(string $modelClassName, int $n): void
    {
        /** @var Model $model */
        $model = new $modelClassName();
        if($this->option('fresh')) {
            // もしオプションでデータリセットが指示されていればモデルに紐づくテーブルをまっさらにする
            \DB::statement("truncate {$model->getTable()};");
        }

        /** @var Column[] $columns テーブルのカラム定義を配列にまとめる */
        $columns      = $model->getConnection()->getDoctrineSchemaManager()->listTableColumns($model->getTable());
        /** @var \Doctrine\DBAL\Schema\Table $tableDetail テーブルの詳細定義を呼び出す */
        $tableDetail = $model->getConnection()->getDoctrineSchemaManager()->listTableDetails($model->getTable());
        // ユニーク制約がかかっているカラムを抜き出し
        $uniqColNames = Arr::flatten(array_filter(
            array_map(
                static fn(Index $index) => $index->getColumns(),
                array_filter(
                    $tableDetail->getIndexes(),
                    static fn(Index $index) => $index->isUnique()
                )
            ),
            static fn(array $cs) => count($cs) === 1
        ));
        // テストデータ作成
        $casts    = $model->getCasts(); // テストデータの型の元
        $testData = collect(); // テストデータ置き場
        $bar      = $this->createProgressBar($n);// 進捗表示
        for($i = 0; $i < $n; $i++) {
            $td = [];
            // 各カラムについてテストデータ用の値を生成して配列に格納
            foreach($columns as $col) {
                $td[$colName = $col->getName()] = $this->getOrMakeTestValue($col, $casts, in_array($colName, $uniqColNames, true));
            }
            // テストデータ置き場内にテストデータをプロパティに持つモデルを追加
            $testData->push((new $modelClassName())->forceFill($td));
            // 1000 個毎に INSERT
            if($testData->count() >= 1000) {
                $testData->chunk(100)->each(function($testDataList) use ($model) {
                    try {
                        // @see https://github.com/n1215/eloquent-bulk-save
                        // モデルのコレクションを元にまとめてインサートできるスニペットを利用
                        $model::bulkInsert($testDataList);
                    } catch(QueryException $e) {
                        // エラーが出てもとりあえず続行させる
                        $this->error('sql err' . "\n" . $e->getMessage());
                    }
                });
                $testData = collect();
            }
            $bar->advance();

            if($i === 0) {
                $testData->push($this->makeSpecialRecord($testData->pop()));
            }
        }

        // 1000 個毎の INSERT に入れなかった余りを INSERT
        $model::bulkInsert($testData);
        $bar->finish();
        $bar->clear();
    }

    /** @var array 生成したテストデータをキャッシュする。同名同型データが存在する時に使う */
    private array $testValueCache = [];
    /** @var array ユニーク制約のあるカラムに重複した値を入れないために値を保持しておく */
    private array $usedCache = [];

    /**
     * テストデータを生成する。同名同型データが生成済みなら取得する
     * @param  Column  $col
     * @param  array   $casts
     * @param  bool    $isUnq
     * @return string|int|null
     * @throws \Exception
     */
    protected function getOrMakeTestValue(Column $col, array $casts, bool $isUnq): int|string|null
    {
        $colName  = $col->getName();
        /** @var string $cacheKey 同型同名データであることを確かめられるキャッシュキーを構築 */
        $cacheKey = implode("_", [
            $colName,
            $casts[$colName],
            $col->getLength(),
            $col->getNotnull(),
        ]);
        // 生成されたテストデータがキャッシュ済みならばキャッシュの中からランダムに選ぶ
        if(array_key_exists($cacheKey, $this->testValueCache)) {
            if(!$isUnq) {
                // ユニーク制約抜きならばランダムに取ってそのまま返す
                return $this->testValueCache[$cacheKey][array_rand($this->testValueCache[$cacheKey])];
            }
            // ユニーク制約があるならば重複しない様にキャッシュから値を取り出す
            /** @var string[]|int[]|float[] $cand 使える値の候補。キャッシュ時点で重複している値を除去 */
            $cand = array_unique($this->testValueCache[$cacheKey]);
            $this->usedCache[$cacheKey] ??= [];
            while(!empty($casts)) {
                // キャッシュの中から値をランダムに選び
                $key = array_rand($cand);
                // 未使用ならばそれを使用済みリストに追加した後に返す
                if(!in_array($cand[$key], $this->usedCache[$cacheKey], true)) {
                    $this->usedCache[$cacheKey][] = $cand[$key];
                    return $cand[$key];
                }
                // 使用済みならば候補から除去して次へ
                unset($cand[$key]);
            }
            // ユニーク制約下で空いていないならば新たにデータ生成処理に移動
        }
        // キャッシュ配列が配列に成っていないのならば初期化
        $this->testValueCache[$cacheKey] ??= [];

        // 作るデータの個数を設定。ユニーク制約下で値を探索する時の時間短縮のためにユニーク制約の場合は大量にデータを生成
        $c   = $this->option('count') * ($isUnq ? 100 : 1);
        // 必要なデータは一つだけだが、処理を何度もループさせるのをきらって、十分な量のテストデータをキャッシュ的に保持する様にする
        for($i = 0; $i < $c; $i++) {
            if(!$col->getNotnull() && random_int(1, 100) <= 25) {
                // カラムが null を許すのであれば 25% の確率で null にする
                $val = null;
            } else {
                // カラムの型に応じてテストデータを生成
                // 特定のカラム名ならば、の様な条件でこの処理を拡張するのもよさそう
                $val = match ($casts[$colName]) {
                    'string'          => $this->makeStringTestData($col),
                    'date','datetime' => date('Y-m-d H:i:s', random_int(strtotime('-1 months'), strtotime('+1 months'))),
                    'integer','int'   => random_int(0, 10000),
                    'float'           => random_int(0, 10000) + mt_rand() / mt_getrandmax(),
                };
            }
            // 出来上がった値をキャッシュに保持
            $this->testValueCache[$cacheKey][] = $val;
        }

        return $val ?? null;
    }

    /**
     * カラムのコメントとランダム文字列で string 型のランダムな値を生成。
     * @param  Column  $col
     * @return string
     */
    private function makeStringTestData(Column $col): string
    {
        $base    = ($col->getComment() ?? $col->getName()) . '_' . Str::random(6);
        $trimmed = substr($base, -$col->getLength());

        // マルチバイト文字を substr で切ると半端な分割で文字化けしやすいのでそれを対処
        // mb_substr の場合、カラムの長さと突き合せるのが面倒そうだったのでこの形
        return iconv('UTF-8', 'UTF-8//IGNORE', $trimmed);
    }
    
    /**
     * /app/Models/Eloquents 以下に配置した Eloquent クラスのクラス名一覧を取得する
     * @see https://cpoint-lab.co.jp/article/202112/21564/
     * @return array
     */
    protected static function getEloquentClassNames(): array
    {
        // プロジェクトの Eloquents を置いてあるディレクトリ以下の PHP ファイルを探索
        // この探索は再帰的に行われる
        // @see https://symfony.com/doc/current/components/finder.html
        $files = (new Finder())->in(app_path('Models/Eloquents'))
            ->files()
            ->name('*.php');
        // 見つかったファイルパスを元にクラス名一覧を作る
        $classNames = [];
        /** @var SplFileInfo $fileInfo */
        foreach($files->getIterator() as $fileInfo) {
            $classNames[] = str_replace(
            // ファイルパスを名前空間に入れ替え、拡張子を除去
                [app_path('Models/Eloquents'), '/', ".php"],
                ['App\\Models\\Eloquents', '\\', ''],
                $fileInfo->getRealPath()
            );
        }
        // クラス名の中から Eloquent を継承したクラスのみを抜き出す
        $eloquentClassNames = [];
        foreach($classNames as $className) {
            try {
                // クラス名からインスタンスを作成。 Eloquent を継承しているか確認
                $instance = new $className();
                if($instance instanceof \Illuminate\Database\Eloquent\Model) {
                    $eloquentClassNames[] = $className;
                }
            } catch(\Error $exception) {
                // インスタンス化できない対象について new を行った際のエラーを握りつぶす
                // abstract class や trait が引っかかりやすい
            }
        }

        return $eloquentClassNames;
    }
}
>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG