【Laravel】外部キー制約から Eloquent のリレーションのソースコードを生成する

 Doctrine でデータベースを元に Eloqunet のソースコードを生成する方法を以前紹介しました。

【Laravel】データベースから Eloquent モデルのソースコードを生成する – 株式会社シーポイントラボ | 浜松のシステム・RTK-GNSS開発

 紹介しましたが、この記事の例だけならばphp artisan make:model クラス名で Eloquent モデルのソースコードを生成するのと大して変わりません。本来できないけれども出来たら便利なことをできる様にしていきます。できるようになると便利ことの一つがリレーションメソッドのソースコードの生成です。よくある Eloquent ソースコードのジェネレーターライブラリでもサポートしているこれができ、更にプラスアルファができることによって学習用の車輪の再発明のみでなく実用的なものになり、自作のソースコード生成コマンドを使う動機が生まれます。

 リレーションのソースコードを生成するのに使うのは例によって DBAL の SchemaManager です。

DBAL Documentation – Doctrine Database Abstraction Layer (DBAL)
Schema-Manager – Doctrine Database Abstraction Layer (DBAL)

 SchemaManager の中には listTableForeignKeys というテーブルの持つ外部キー制約を取得するメソッドがあり、これを元に外部キー制約をまとめて取得、モデルに紐づくテーブルに関連する外部キーを抽出、外部キーを元にリレーションのソースコードを生成、という流れでリレーションのソースコードを作ります。これは次でできます。

<?php
// app/Console/Commands/ForDevelop/ModelMaker/RelationDemo.php

namespace App\Console\Commands\ForDevelop\ModelMaker;

use Arr;
use DB;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\DBAL\Schema\ForeignKeyConstraint;
use Doctrine\DBAL\Schema\Table;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Str;
use View;

/** リレーションのソースコードを構築するデモ */
class RelationDemo extends Command
{
    protected $name = 'relation-demo';
    private AbstractSchemaManager $schemaManager;

    /**
     * @return int
     * @throws Exception
     */
    public function handle(): int
    {
        // doctrine を用意
        $this->schemaManager = DB::getDoctrineSchemaManager();

        // 外部キー制約全体を取得。多次元配列にすることによって、どこからどこへのリレーションをキーで一意に取得できる様にしています
        /** @var ForeignKeyConstraint[][][] ForeignKeyConstraint[外部キー制約を持つテーブル名][外部キー制約先のテーブル名][index] 複数の外部キー制約を同じテーブル間で持つ場合を考慮 */
        $foreignKeys = $this->getForeignKeys();

        // テーブルを全部回します。
        /** @var Table $table */
        foreach($this->schemaManager->listTables() as $table) {
            if($table->getName() === 'migrations') {
                continue; // 出力したくないテーブルはこんな感じでスキップ
            }
            // テーブルに関連するモデルのリレーションメソッドを構成する情報を取得
            $relations = $this->getRelationViewDataList($table, $foreignKeys);

            // view を生成してソースコードとして扱える文字列化
            $view = View::file(__DIR__.'/../CodeStab/ModelRelationPart.php.stub.blade.php', compact('relations'));
            $srcCode = $view->render();

            // この後はモデル全体の処理に組み込んだり、既存のモデルの末尾に追記したりします。
            // ここではデモ用に表示
            $this->info('------------------------------------');
            $this->info('table: '.$table->getName());
            $this->info($srcCode);
            $this->info('------------------------------------');
            // 会社が部署を 1:n で持っているリレーションのソースコードです。インデントがずれたり、クラス名が冗長なので IDE や php-cs-fixer で後からまとめて整えます
            // ------------------------------------
            //table: companies
            //public function companyDepartments(): \Illuminate\Database\Eloquent\Relations\HasMany
            //    {
            //        return $this->hasMany(\App\Models\Eloquents\CompanyDepartment::class, 'company_id');
            //    }
            //
            //------------------------------------
        }

        return 0;
    }

    /**
     * 外部キー制約をリストアップ
     * @return ForeignKeyConstraint[][][] ForeignKeyConstraint[外部キー制約を持つテーブル名][外部キー制約先のテーブル名][index] 複数の外部キー制約を同じテーブル間で持つ場合を考慮
     * @throws Exception
     */
    private function getForeignKeys(): array
    {
        // 返り値あである外部キー制約らを保持する変数
        $foreignKeys = [];
        foreach($this->schemaManager->listTableNames() as $tableName) {
            /** @var ForeignKeyConstraint[] $fKeys あるテーブルの外部キー制約一覧 */
            $fKeys = $this->schemaManager->listTableForeignKeys($tableName);

            // あるテーブルについての外部キー制約をまとめる配列を用意
            $foreignKeys[$tableName] = [];
            foreach($fKeys as $f) {
                // 外部キー制約に関わるテーブル両方をキーにして外部キー制約を格納
                //  後でキーを元にあるテーブルが関わる外部キー制約を引き出しやすい様にする
                $foreignKeys[$tableName][$f->getForeignTableName()]   ??= [];
                $foreignKeys[$tableName][$f->getForeignTableName()][] = $f;
            }
        }

        return $foreignKeys;
    }

    /**
     * 出力するソースコードの view に渡したい情報を返す。内容は以下
     * <ul>
     * <li>       retClassName: 返り値のクラス名。BelongTo, HasMany の名前空間つきのフルパス</li>
     * <li>          useMethod: メソッド内部で使うリレーションメソッドの名前。belongTo, hasMany</li>
     * <li>   keyColumnPHPCode: リレーションのキーとなるカラムの名前</li>
     * <li>         methodName: リレーションメソッドそのものの名前</li>
     * <li>tgtClassNamePHPCode: リレーション先のクラス名。名前空間つきのフルパス</li>
     * </ul>
     * @param  Table                       $table        リレーションソースコードが欲しいモデルの参照するテーブル
     * @param  ForeignKeyConstraint[][][]  $foreignKeys  ForeignKeyConstraint[外部キー制約を持つテーブル名][外部キー制約先のテーブル名][index] 複数の外部キー制約を同じテーブル間で持つ場合を考慮
     *
     * @return array{retClassName: string, useMethod: string, keyColumnPHPCode: string, methodName: string, tgtClassNamePHPCode: string }[]
     */
    private function getRelationViewDataList(Table $table, array $foreignKeys): array
    {
        $belongToList = $this->getBelongToViewDataList($table, $foreignKeys);
        $hasManyList  = $this->getHasManyViewDataList($table, $foreignKeys);

        // 用意した各リレーション用の情報を一次元配列にまとめて返却
        return [...$belongToList, ...$hasManyList];
    }

    /** belongTo となる 引数のテーブル:リレーション先のテーブル = n:1 となるリレーションをまとめます。 */
    private function getBelongToViewDataList(Table $table, array $foreignKeys): array
    {
        $belongToList = [];
        foreach($foreignKeys[$table->getName()] as $fKeyList) {
            /** @var ForeignKeyConstraint $fKey */
            foreach($fKeyList as $fKey) {
                $belongToList[] = [
                    // リレーションメソッドの返り値になるクラス名を用意。頭の \ がないと undefined になる
                    'retClassName'        => '\\' . BelongsTo::class,
                    // return $this->belongTo(リレーション定義) な感じで用いるメソッド名
                    'useMethod'           => 'belongsTo',
                    // 外部キー制約で用いているカラムについての情報のソースコード上で表示する文字列そのものの状態
                    // view 上では配列でも文字列でも同じ呼び出し方をしたかったのでこの形
                    'keyColumnPHPCode'    => count($fKey->getForeignColumns()) === 1
                        ? "'" . Arr::first($fKey->getForeignColumns()) . "'"
                        : "['" . implode("', '", $fKey->getForeignColumns()) . "']",
                    // リレーションメソッドの名前。belongTo の場合のリレーション先は一つだけなので、テーブル名を単数形にする
                    'methodName'          => Str::singular($fKey->getForeignTableName()),
                    // リレーション先のクラス名をソースコード上で表示する文字列そのものの状態で用意
                    // Eloquent のソースコードがなくても出力できる様にしておく
                    'tgtClassNamePHPCode' => '\\App\\Models\\Eloquents\\' . ucfirst(Str::singular($fKey->getForeignTableName())) . '::class'
                ];
            }
        }
        return $belongToList;
    }

    /** hasMany となる 引数のテーブル:リレーション先のテーブル = 1:n となるリレーションをまとめます。 */
    private function getHasManyViewDataList(Table $table, array $foreignKeys): array
    {
        $hasManyList = [];
        foreach($foreignKeys as $hasIndexTableName => $foreignKeyTgtTableList) {
            // 引数のテーブル名をキーに持つ外部キー配列を持つならば
            if(isset($foreignKeyTgtTableList[$table->getName()]) && !empty($foreignKeyTgtTableList[$table->getName()])) {
                foreach($foreignKeyTgtTableList[$table->getName()] as $fKey) {
                    // belongTo 同様の構造で配列に格納
                    $hasManyList[] = [
                        'retClassName'        => '\\' . HasMany::class,
                        'useMethod'           => 'hasMany',
                        'keyColumnPHPCode'    => count($fKey->getForeignColumns()) === 1
                            ? "'" . Arr::first($fKey->getForeignColumns()) . "'"
                            : "['" . implode("', '", $fKey->getForeignColumns()) . "']",
                        'methodName'          => Str::camel($hasIndexTableName),
                        'tgtClassNamePHPCode' => '\\App\\Models\\Eloquents\\' . ucfirst(Str::singular(Str::camel($hasIndexTableName))) . '::class'
                    ];
                }
            }
        }
        return $hasManyList;
    }

}
@php
// app/Console/Commands/ForDevelop/CodeStab/ModelRelationPart.php.stub.blade.php
/** @var array{retClassName: string, useMethod: string, keyColumnPHPCode: string, methodName: string, tgtClassNamePHPCode: string } $rel */
@endphp
@foreach( $relations as $rel)
    public function {{ $rel['methodName'] }}(): {{ $rel['retClassName'] }}
    {
        return $this->{{ $rel['useMethod'] }}({!! $rel['tgtClassNamePHPCode'] !!}, {!! $rel['keyColumnPHPCode'] !!});
    }
@endforeach

 例の様に外部キー制約から関連するテーブル、カラムの情報を集めることによってリレーションメソッドのソースコードを構築するのに必要な情報が集まり、それを元にソースコードを生成できます。例では、代表的なリレーションである HasMany、BelongTo のみを扱っていますが、リレーションを辿るなどすれば HasManyThrough の様なより複雑なリレーションにも対応できます。

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

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

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

CTR IMG