はじめに
E-kanの前中です。
Laravelを業務で使うようになって2年以上経ちました。
最初は見よう見まねで取り組んできましたが、整理のために備忘録を兼ねて記事にしてみました。
Eloquentでツリー構造に挑戦する
LaravelではEloquentモデルを使ってDBのテーブル同士のリレーションを定義して、関係のあるテーブルのデータをまとめて扱うことができます。
実務上では「1対多」のリレーションを使う機会が多いと思いますが、「1対多」のリレーションの応用として、ツリー構造を持つモデルを定義できないか、試してみました。
ツリー構造というのは、PCのディレクトリ構造が良く例として挙げられますが、他にも上司と部下の関係をあらわす場合などに使われます。
例えば
社長
├部長1
│ ├課長A
│ ├課長B
│ └課長C
└部長2
├課長D
└課長E
├係長a
└係長b
のような構造です。
データベースで扱う場合は、以下のように上司をあらわすカラムを持つテーブルになります。
ID | 上司 | 名前 | 役職 |
---|---|---|---|
1 | Null | 斎藤 | 社長 |
2 | 斎藤 | 山田 | 部長 |
3 | 斎藤 | 木下 | 部長 |
4 | 山田 | 金山 | 課長 |
5 | 山田 | 後藤 | 課長 |
6 | 木下 | 佐々木 | 課長 |
7 | 木下 | 小野寺 | 課長 |
8 | 後藤 | 飯田 | 係長 |
※実際には上司のIDで参照するべきですが、見やすさ優先ということで。
※このあたりのテーブル設計については「入れ子集合モデル」などで調べるとよいです。
ソースを書いてみる
要は、自分自身に対してリレーションを持つテーブルということになりますので、1対多のリレーションを設定するhasMany()メソッドを使って定義すれば良いわけです。
役職でも良いのですが、地域を扱うモデルとして作ってみます。
まずはマイグレーションファイルを作ります。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAreasTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('areas', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('memo')->nullable(true);
$table->integer('parent_area_id')->nullable(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('areas');
}
}
id、name、memo、parent_area_idとcreated_atとupdated_atのカラムを持つだけの割とシンプルなテーブルです。
※ $table->timestamps(); でcreated_atとupdated_atのカラムを作成できます。
parent_area_id カラムはnullを許容するようにします。親Areaを持たないデータがあるからです。先の例でいうなら、社長の上司が居ないのにあたります。
続いてModelを作成します。この中でリレーションも定義します。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Area extends Model
{
use HasFactory;
protected $table = 'areas';
function childAreas()
{
return $this->hasMany(self::class, 'parent_area_id', 'id');
}
}
childAreas()関数の中で自分自身に対して1対多のリレーションを定義しておきます。
テストも作ってみます。
<?php
namespace Tests\Unit\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Area;
class AreaTest extends TestCase
{
use RefreshDatabase;
public function test_get_child_areas()
{
Area::factory()
->hasChildAreas(3)
->create();
$area = Area::get();
$this->assertCount(4, $area);
$this->assertcount(3, $area->first()->childAreas);
}
}
各関数の説明は省きますが、ここでは子Areaを3つ持つAreaを1つ生成させています。
つまり、Areaのレコードが4件作成され、親レコードの子レコードを参照すると3件のレコードが得られるかを確認しています。
注意点
結構簡単にModelを作成することができました。
あとは逆引きで親レコードを参照できるようにしたり、Rootレコード(parent_area_idがnullのレコード)を抽出する関数を定義したりすれば使い物になりそうです。
しかし、こういった構造を持つModelを扱う際には注意が必要です。
循環参照
一番の注意点は循環参照でしょう。
これは、階層構造を持つデータを作成したうえで、何らかの変更が必要になってRootレコードに親レコードを設定する時に起こりえます。
無関係のレコードを親に設定すれば問題は発生しませんが、既に自身の子レコードや孫レコードとして設定しているレコードを親にすればどうなるでしょうか?
永遠に参照が止まらなくなってしまいますね。処理がタイムアウトエラーになればまだマシな方で、タイムアウトしない設定にしていれば、永遠に処理中になってしまいます。
次回はこの循環参照の回避策について考えてみようと思います。
回避策の候補は2つありますので、大雑把に記載しておきます。
回避策1
データ編集時に循環参照のチェック処理を実装し、循環参照のデータ作成を阻止する。
実務的にもこの処理が最適でしょう。ただし、プログラム以外からデータ編集をされてしまうとどうしようもありません。
※ 「そんな奴おらへんやろ」と思った貴方は善良な人です。馬鹿げた想定が現実になるのが世の中ってものです。
回避策2
子レコードを参照する際に同一IDが2回目に現れた時点で参照を停止する。
意地でもエラーにさせないというなら、こうするしかありませんが、実務上はここまでやる必要はないでしょう。
現実的ではないと思いますが、ストレッチ課題として挑戦してみるのも良いかもしれません。
さいごに
LaravelのEloquentモデルは非常に便利です。
便利なのですが、習得するためには結構な時間がかかります。
DBのリレーション構造を意識しなければいけませんし、何よりも適切に設計されたテーブルとの相性が良いので、そちらも学ばなければ使いこなすことは難しいでしょう。